You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

ansys-additive-core

Package Overview
Dependencies
Maintainers
1
Versions
70
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ansys-additive-core - pypi Package Compare versions

Comparing version
0.19.2
to
0.18.3
+251
src/ansys/additive...e/parametric_study/parametric_runner.py
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Provides a class to run parametric study simulations."""
from __future__ import annotations
import numpy as np
import pandas as pd
from ansys.additive.core import (
LOG,
Additive,
AdditiveMachine,
AdditiveMaterial,
MachineConstants,
MicrostructureInput,
MicrostructureSummary,
PorosityInput,
PorositySummary,
SimulationStatus,
SimulationType,
SingleBeadInput,
SingleBeadSummary,
)
from ansys.additive.core.parametric_study.constants import ColumnNames
class ParametricRunner:
"""Provides methods to run parametric study simulations."""
@staticmethod
def simulate(
df: pd.DataFrame,
additive: Additive,
type: list[SimulationType] = None,
priority: int = None,
iteration: int = None,
) -> list[SingleBeadSummary, PorositySummary, MicrostructureSummary]:
"""Run the simulations in the parametric study with ``Status`` equal to ``Pending``.
Execution order is determined by the ``Priority`` value assigned to the simulations.
Lower values are interpreted as having higher priority and are run first.
Parameters
----------
df : pd.DataFrame
Parametric study data frame.
additive : Additive
Additive service connection to use for running simulations.
type : list, default: None
List of the simulation types to run. The default is ``None``, in which case all
simulation types are run.
priority : int, default: None
Priority of simulations to run. The default is ``None``, in which case
all priorities are run.
iteration : int, default: None
Iteration number of simulations to run. The default is ``None``, in which case
all iterations are run.
Returns
-------
list[SingleBeadSummary, PorositySummary, MicrostructureSummary]
List of simulation summaries.
"""
if type is None:
type = [
SimulationType.SINGLE_BEAD,
SimulationType.POROSITY,
SimulationType.MICROSTRUCTURE,
]
elif not isinstance(type, list):
type = [type]
view = df[
(df[ColumnNames.STATUS] == SimulationStatus.PENDING) & df[ColumnNames.TYPE].isin(type)
]
if priority is not None:
view = view[view[ColumnNames.PRIORITY] == priority]
view = view.sort_values(by=ColumnNames.PRIORITY, ascending=True)
if iteration is not None:
view = df[(df[ColumnNames.ITERATION] == iteration)]
inputs = []
# NOTICE: We use iterrows() instead of itertuples() here to
# access values by column name
for _, row in view.iterrows():
try:
material = additive.material(row[ColumnNames.MATERIAL])
except Exception:
print(
f"Material {row[ColumnNames.MATERIAL]} not found, skipping {row[ColumnNames.ID]}"
)
continue
machine = ParametricRunner._create_machine(row)
sim_type = row[ColumnNames.TYPE]
if sim_type == SimulationType.SINGLE_BEAD:
inputs.append(ParametricRunner._create_single_bead_input(row, material, machine))
elif sim_type == SimulationType.POROSITY:
inputs.append(ParametricRunner._create_porosity_input(row, material, machine))
elif sim_type == SimulationType.MICROSTRUCTURE:
inputs.append(ParametricRunner._create_microstructure_input(row, material, machine))
else: # pragma: no cover
print(
f"Invalid simulation type: {row[ColumnNames.TYPE]} for {row[ColumnNames.ID]}, skipping"
)
continue
if len(inputs) == 0:
LOG.warning("None of the input simulations meet the criteria selected")
return []
summaries = additive.simulate(inputs)
# TODO: Return the summaries one at a time, possibly as an iterator,
# so that the data frame can be updated as each simulation completes.
return summaries
@staticmethod
def _create_machine(row: pd.Series) -> AdditiveMachine:
return AdditiveMachine(
laser_power=row[ColumnNames.LASER_POWER],
scan_speed=row[ColumnNames.SCAN_SPEED],
layer_thickness=row[ColumnNames.LAYER_THICKNESS],
beam_diameter=row[ColumnNames.BEAM_DIAMETER],
heater_temperature=row[ColumnNames.HEATER_TEMPERATURE],
starting_layer_angle=(
row[ColumnNames.START_ANGLE]
if not np.isnan(row[ColumnNames.START_ANGLE])
else MachineConstants.DEFAULT_STARTING_LAYER_ANGLE
),
layer_rotation_angle=(
row[ColumnNames.ROTATION_ANGLE]
if not np.isnan(row[ColumnNames.ROTATION_ANGLE])
else MachineConstants.DEFAULT_LAYER_ROTATION_ANGLE
),
hatch_spacing=(
row[ColumnNames.HATCH_SPACING]
if not np.isnan(row[ColumnNames.HATCH_SPACING])
else MachineConstants.DEFAULT_HATCH_SPACING
),
slicing_stripe_width=(
row[ColumnNames.STRIPE_WIDTH]
if not np.isnan(row[ColumnNames.STRIPE_WIDTH])
else MachineConstants.DEFAULT_SLICING_STRIPE_WIDTH
),
)
@staticmethod
def _create_single_bead_input(
row: pd.Series, material: AdditiveMaterial, machine: AdditiveMachine
) -> SingleBeadInput:
return SingleBeadInput(
id=row[ColumnNames.ID],
material=material,
machine=machine,
bead_length=row[ColumnNames.SINGLE_BEAD_LENGTH],
)
@staticmethod
def _create_porosity_input(
row: pd.Series, material: AdditiveMaterial, machine: AdditiveMachine
) -> PorosityInput:
return PorosityInput(
id=row[ColumnNames.ID],
material=material,
machine=machine,
size_x=row[ColumnNames.POROSITY_SIZE_X],
size_y=row[ColumnNames.POROSITY_SIZE_Y],
size_z=row[ColumnNames.POROSITY_SIZE_Z],
)
@staticmethod
def _create_microstructure_input(
row: pd.Series, material: AdditiveMaterial, machine: AdditiveMachine
) -> MicrostructureInput:
use_provided_thermal_param = (
not np.isnan(row[ColumnNames.COOLING_RATE])
or not np.isnan(row[ColumnNames.THERMAL_GRADIENT])
or not np.isnan(row[ColumnNames.MICRO_MELT_POOL_WIDTH])
or not np.isnan(row[ColumnNames.MICRO_MELT_POOL_DEPTH])
)
return MicrostructureInput(
id=row[ColumnNames.ID],
material=material,
machine=machine,
sample_size_x=row[ColumnNames.MICRO_SIZE_X],
sample_size_y=row[ColumnNames.MICRO_SIZE_Y],
sample_size_z=row[ColumnNames.MICRO_SIZE_Z],
sensor_dimension=row[ColumnNames.MICRO_SENSOR_DIM],
use_provided_thermal_parameters=use_provided_thermal_param,
sample_min_x=(
row[ColumnNames.MICRO_MIN_X]
if not np.isnan(row[ColumnNames.MICRO_MIN_X])
else MicrostructureInput.DEFAULT_POSITION_COORDINATE
),
sample_min_y=(
row[ColumnNames.MICRO_MIN_Y]
if not np.isnan(row[ColumnNames.MICRO_MIN_Y])
else MicrostructureInput.DEFAULT_POSITION_COORDINATE
),
sample_min_z=(
row[ColumnNames.MICRO_MIN_Z]
if not np.isnan(row[ColumnNames.MICRO_MIN_Z])
else MicrostructureInput.DEFAULT_POSITION_COORDINATE
),
cooling_rate=(
row[ColumnNames.COOLING_RATE]
if not np.isnan(row[ColumnNames.COOLING_RATE])
else MicrostructureInput.DEFAULT_COOLING_RATE
),
thermal_gradient=(
row[ColumnNames.THERMAL_GRADIENT]
if not np.isnan(row[ColumnNames.THERMAL_GRADIENT])
else MicrostructureInput.DEFAULT_THERMAL_GRADIENT
),
melt_pool_width=(
row[ColumnNames.MICRO_MELT_POOL_WIDTH]
if not np.isnan(row[ColumnNames.MICRO_MELT_POOL_WIDTH])
else MicrostructureInput.DEFAULT_MELT_POOL_WIDTH
),
melt_pool_depth=(
row[ColumnNames.MICRO_MELT_POOL_DEPTH]
if not np.isnan(row[ColumnNames.MICRO_MELT_POOL_DEPTH])
else MicrostructureInput.DEFAULT_MELT_POOL_DEPTH
),
random_seed=(
row[ColumnNames.RANDOM_SEED]
if not np.isnan(row[ColumnNames.RANDOM_SEED])
else MicrostructureInput.DEFAULT_RANDOM_SEED
),
)
+11
-8
Metadata-Version: 2.4
Name: ansys-additive-core
Version: 0.19.2
Version: 0.18.3
Summary: A Python client for the Ansys Additive service
Author-email: "ANSYS, Inc." <pyansys.core@ansys.com>
Maintainer-email: "ANSYS, Inc." <pyansys.core@ansys.com>
Requires-Python: >=3.10,<4
Requires-Python: >=3.9,<4
Description-Content-Type: text/x-rst

@@ -18,3 +18,3 @@ Classifier: Development Status :: 4 - Beta

License-File: LICENSE
Requires-Dist: ansys-api-additive==2.2.1
Requires-Dist: ansys-api-additive==1.7.2
Requires-Dist: ansys-platform-instancemanagement>=1.1.1

@@ -29,9 +29,8 @@ Requires-Dist: ansys-tools-common>=0.4.0

Requires-Dist: numpy>=1.20.3
Requires-Dist: pandas>=2.2.2
Requires-Dist: pandas>=1.3.2
Requires-Dist: platformdirs>=3.8.0
Requires-Dist: protobuf>=3.20.2,<6
Requires-Dist: protobuf>=3.20.2,<5
Requires-Dist: six>=1.16.0
Requires-Dist: tqdm>=4.45.0
Requires-Dist: pydantic>=2.6.3
Requires-Dist: ipython>=7.0.0
Requires-Dist: ansys-sphinx-theme[autoapi]==1.5.0 ; extra == "doc"

@@ -69,4 +68,4 @@ Requires-Dist: enum-tools==0.12.0 ; extra == "doc"

Requires-Dist: pandas==2.2.3 ; extra == "tests"
Requires-Dist: platformdirs==4.3.6 ; extra == "tests"
Requires-Dist: protobuf==5.28.3 ; extra == "tests"
Requires-Dist: platformdirs==4.5.1 ; extra == "tests"
Requires-Dist: protobuf==4.25.3 ; extra == "tests"
Requires-Dist: six==1.16.0 ; extra == "tests"

@@ -85,2 +84,4 @@ Requires-Dist: tqdm==4.66.5 ; extra == "tests"

.. _ref_readme:
##########

@@ -90,2 +91,4 @@ PyAdditive

.. readme_start
|pyansys| |python| |pypi| |GH-CI| |codecov| |MIT| |black|

@@ -92,0 +95,0 @@

[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
requires = ["flit_core >=3.2,<4"]
[project]
# Check https://flit.readthedocs.io/en/latest/pyproject_toml.html for all available sections
authors = [{name = "ANSYS, Inc.", email = "pyansys.core@ansys.com"}]
name = "ansys-additive-core"
version = "0.18.3"
description = "A Python client for the Ansys Additive service"
readme = "README.rst"
requires-python = ">=3.9,<4"
license = { file = "LICENSE" }
authors = [{ name = "ANSYS, Inc.", email = "pyansys.core@ansys.com" }]
maintainers = [{ name = "ANSYS, Inc.", email = "pyansys.core@ansys.com" }]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Manufacturing",
"Topic :: Scientific/Engineering",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Development Status :: 4 - Beta",
"Intended Audience :: Manufacturing",
"Topic :: Scientific/Engineering",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
description = "A Python client for the Ansys Additive service"
license = {file = "LICENSE"}
maintainers = [{name = "ANSYS, Inc.", email = "pyansys.core@ansys.com"}]
name = "ansys-additive-core"
readme = "README.rst"
requires-python = ">=3.10,<4"
version = "0.19.2"
dependencies = [
"ansys-api-additive==2.2.1",
"ansys-platform-instancemanagement>=1.1.1",
"ansys-tools-common>=0.4.0",
"dill>=0.3.7",
"google-api-python-client>=1.7.11",
"googleapis-common-protos>=1.52.0",
"grpcio>=1.63.0",
"grpcio-health-checking>=1.60.0",
"importlib-metadata>=4.0",
"numpy>=1.20.3",
"pandas>=2.2.2",
"platformdirs>=3.8.0",
"protobuf>=3.20.2,<6",
"six>=1.16.0",
"tqdm>=4.45.0",
"pydantic>=2.6.3",
"ipython>=7.0.0",
"ansys-api-additive==1.7.2",
"ansys-platform-instancemanagement>=1.1.1",
"ansys-tools-common>=0.4.0",
"dill>=0.3.7",
"google-api-python-client>=1.7.11",
"googleapis-common-protos>=1.52.0",
"grpcio>=1.63.0",
"grpcio-health-checking>=1.60.0",
"importlib-metadata>=4.0",
"numpy>=1.20.3",
"pandas>=1.3.2",
"platformdirs>=3.8.0",
"protobuf>=3.20.2,<5",
"six>=1.16.0",
"tqdm>=4.45.0",
"pydantic>=2.6.3",
]

@@ -56,4 +55,4 @@

"pandas==2.2.3",
"platformdirs==4.3.6",
"protobuf==5.28.3",
"platformdirs==4.5.1",
"protobuf==4.25.3",
"six==1.16.0",

@@ -94,7 +93,7 @@ "tqdm==4.66.5",

[project.urls]
Source = "https://github.com/ansys/pyadditive"
Issues = "https://github.com/ansys/pyadditive/issues"
Documentation = "https://additive.docs.pyansys.com"
Discussions = "https://github.com/ansys/pyadditive/discussions"
Documentation = "https://additive.docs.pyansys.com"
Issues = "https://github.com/ansys/pyadditive/issues"
Releases = "https://github.com/ansys/pyadditive/releases"
Source = "https://github.com/ansys/pyadditive"

@@ -108,6 +107,6 @@ [tool.flit.module]

[tool.isort]
default_section = "THIRDPARTY"
profile = "black"
force_sort_within_sections = true
line_length = 100
profile = "black"
default_section = "THIRDPARTY"
src_paths = ["doc", "src", "tests"]

@@ -122,116 +121,12 @@

[tool.pytest.ini_options]
minversion = "7.1"
addopts = "-ra --cov=ansys.additive.core --cov-report html:.cov/html --cov-report xml:.cov/xml --cov-report term -vv --cov-fail-under 95"
testpaths = ["tests"]
filterwarnings = ["ignore:::.*protoc_gen_swagger*"]
minversion = "7.1"
testpaths = ["tests"]
[tool.interrogate]
ignore-magic = true
ignore-semiprivate = true
ignore-private = true
ignore-semiprivate = true
ignore-setters = true
ignore-magic = true
verbose = 1
[tool.ruff]
exclude = ["doc", "examples", "tests"]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
ignore = [
# "D100", # pydocstyle - missing docstring in public module
# "D101", # pydocstyle - missing docstring in public class
# "D102", # pydocstyle - missing docstring in public method
# "D103", # pydocstyle - missing docstring in public function
"D104", # pydocstyle - missing docstring in public package
"D105", # pydocstyle - missing docstring in magic method
"D106", # pydocstyle - missing docstring in public nested class
# "D107", # pydocstyle - missing docstring in __init__
"D202", # pydocstyle - no blank lines allowed after function docstring
"D203", # pydocstyle - 1 blank line required before class docstring
"D204", # pydocstyle - 1 blank line required after class docstring
"D205", # pydocstyle - 1 blank line required between summary line and description
"D212", # pydocstyle - multi-line docstring summary should start at the first line
"D213", # pydocstyle - multi-line docstring summary should start at the second line
"E501", # pycodestyle line too long, handled by formatting
"ISC001", # Ruff formatter incompatible
"S101", # flake8-bandit - use of assert
"ERA001", # eradicate - commented out code
]
select = [
# "A", # flake8-builtins
# "ANN", # flake8-annotations
# "ARG", # flake8-unused-arguments
"ASYNC", # flake8-async
"B", # flake8-bugbear
# "BLE", # flake8-blind-except
"C4", # flake8-comprehensions
# "C90", # mccabe
# "CPY", # flake8-copyright
"D", # pydocstyle
# "DJ", # flake8-django
# "DTZ", # flake8-datetimez
"E", # pycodestyle errors
# "EM", # flake8-errmsg
"ERA", # eradicate
"EXE", # flake8-executable
"F", # pyflakes
# "FA", # flake8-future-annotations
# "FBT", # flake8-boolean-trap
# "FIX", # flake8-fixme
"FLY", # flying
# "FURB", # refurb
# "G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"ISC", # flake8-implicit-str-concat
"INP", # flake8-no-pep420
"LOG", # flake8-logging
# "N", # pep8-naming
# "PD", # pandas-vet
"PIE", # flake8-pie
"PLC", # pylint - convention
"PLE", # pylint - error
# "PLR", # pylint - refactor
"PLW", # pylint - warning
# "PT", # flake8-pytest-style
# "PTH", # flake8-use-pathlib
"PYI", # flake8-pyi
"Q", # flake8-quotes
# "RET", # flake8-return
"RSE", # flake8-raise
# "RUF", # Ruff-specific rules
"S", # flake8-bandit
"SIM", # flake8-simplify
# "SLF", # flake8-self
"SLOT", # flake8-slot
"T10", # flake8-debugger
"T20", # flake8-print
"TCH", # flake8-type-checking
# "TD", # flake8-todos
"TID", # flake8-tidy-imports
# "TRIO", # flake8-trio
# "TRY", # tryceratops
# "UP", # pyupgrade
"W", # pycodestyle - warning
"YTT", # flake8-2020
]
[tool.ruff.lint.per-file-ignores]
"dev/scripts/*" = [
"D", # all docstring rules
"INP001", # implicit namespace package
]
"examples/*" = [
"D", # all docstring rules
"E402", # module level import not at top of file
"T201", # print statement
]
"src/ansys/additive/core/logger.py" = [
"PLW0642", # reassigned self
]
"tests/*" = ["D"]
[tool.ruff.format]
indent-style = "space"
quote-style = "double"

@@ -0,1 +1,3 @@

.. _ref_readme:
##########

@@ -5,2 +7,4 @@ PyAdditive

.. readme_start
|pyansys| |python| |pypi| |GH-CI| |codecov| |MIT| |black|

@@ -7,0 +11,0 @@

@@ -23,3 +23,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Python client for the Ansys Additive service."""
import os

@@ -79,4 +78,2 @@

)
from ansys.additive.core.simulation_task import SimulationTask # noqa: F401, E402
from ansys.additive.core.simulation_task_manager import SimulationTaskManager # noqa: F401, E402
from ansys.additive.core.single_bead import ( # noqa: F401, E402

@@ -83,0 +80,0 @@ MeltPool,

@@ -24,32 +24,28 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

from __future__ import annotations
from collections.abc import Iterator
import concurrent.futures
from datetime import datetime
import hashlib
import logging
import os
import time
from pathlib import Path
import zipfile
from ansys.api.additive import __version__ as api_version
from ansys.api.additive.v0.additive_materials_pb2 import GetMaterialRequest
from ansys.api.additive.v0.additive_simulation_pb2 import UploadFileRequest
from google.protobuf.empty_pb2 import Empty
import grpc
from google.longrunning.operations_pb2 import Operation
from google.protobuf.empty_pb2 import Empty
import ansys.additive.core.misc as misc
from ansys.additive.core import USER_DATA_PATH, __version__
from ansys.additive.core.download import download_file
from ansys.additive.core.exceptions import BetaFeatureNotEnabledError
from ansys.additive.core.logger import LOG
from ansys.additive.core.material import RESERVED_MATERIAL_NAMES, AdditiveMaterial
from ansys.additive.core.material_tuning import (
MaterialTuningInput,
MaterialTuningSummary,
)
from ansys.additive.core.microstructure import (
MicrostructureInput,
MicrostructureSummary,
)
from ansys.additive.core.microstructure_3d import (
Microstructure3DInput,
Microstructure3DSummary,
)
from ansys.additive.core.parametric_study import ParametricStudy
from ansys.additive.core.parametric_study.parametric_study_progress_handler import (
ParametricStudyProgressHandler,
)
from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.material_tuning import MaterialTuningInput, MaterialTuningSummary
from ansys.additive.core.microstructure import MicrostructureInput, MicrostructureSummary
from ansys.additive.core.microstructure_3d import Microstructure3DInput, Microstructure3DSummary
import ansys.additive.core.misc as misc
from ansys.additive.core.porosity import PorosityInput, PorositySummary

@@ -59,29 +55,10 @@ from ansys.additive.core.progress_handler import (

IProgressHandler,
Progress,
ProgressState,
)
from ansys.additive.core.server_connection import (
DEFAULT_PRODUCT_VERSION,
ServerConnection,
)
from ansys.additive.core.server_connection import DEFAULT_PRODUCT_VERSION, ServerConnection
from ansys.additive.core.server_connection.constants import TransportMode
from ansys.additive.core.simulation import (
SimulationError,
SimulationStatus,
SimulationType,
)
from ansys.additive.core.simulation_requests import create_request
from ansys.additive.core.simulation_task import SimulationTask
from ansys.additive.core.simulation_task_manager import SimulationTaskManager
from ansys.additive.core.simulation import SimulationError
from ansys.additive.core.single_bead import SingleBeadInput, SingleBeadSummary
from ansys.additive.core.thermal_history import (
ThermalHistoryInput,
ThermalHistorySummary,
)
from ansys.api.additive import __version__ as api_version
from ansys.api.additive.v0.additive_materials_pb2 import (
AddMaterialRequest,
GetMaterialRequest,
RemoveMaterialRequest,
)
from ansys.api.additive.v0.additive_operations_pb2 import OperationMetadata
from ansys.api.additive.v0.additive_settings_pb2 import SettingsRequest
from ansys.additive.core.thermal_history import ThermalHistoryInput, ThermalHistorySummary

@@ -92,8 +69,17 @@

In a typical cloud environment, a single Additive service with load balancing and
auto-scaling is used. The ``Additive`` client connects to the service via a
single connection. However, for atypical environments or when running on localhost,
the ``Additive`` client can perform crude load balancing by connecting to multiple
servers and distributing simulations across them. You can use the ``server_connections``,
``nservers``, and ``nsims_per_server`` parameters to control the
number of servers to connect to and the number of simulations to run on each
server.
Parameters
----------
channel: grpc.Channel, default: None
Server connection. If provided, it is assumed that the
:class:`grpc.Channel <grpc.Channel>` object is connected to the server.
Also, if provided, the ``host`` and ``port`` parameters are ignored.
server_connections: list[str, grpc.Channel], None
List of connection definitions for servers. The list may be a combination of strings and
connected :class:`grpc.Channel <grpc.Channel>` objects. Strings use the format
``host:port`` to specify the server IPv4 address.
host: str, default: None

@@ -105,5 +91,11 @@ Host name or IPv4 address of the server. This parameter is ignored if the

nsims_per_server: int, default: 1
Number of simultaneous simulations to run on the server. Each simulation
Number of simultaneous simulations to run on each server. Each simulation
requires a license checkout. If a license is not available, the simulation
fails.
nservers: int, default: 1
Number of Additive servers to start and connect to. This parameter is only
applicable in `PyPIM`_-enabled cloud environments and on localhost. For
this to work on localhost, the Additive portion of the Ansys Structures
package must be installed. This parameter is ignored if the ``server_connections``
parameter or ``host`` parameter is other than ``None``.
product_version: str

@@ -115,5 +107,4 @@ Version of the Ansys product installation in the form ``"YYR"``, where ``YY``

or ``None`` uses the default product version.
log_level: str, default: ""
Minimum severity level of messages to log. Valid values are "DEBUG", "INFO",
"WARNING", "ERROR", and "CRITICAL". The default value equates to "WARNING".
log_level: str, default: "INFO"
Minimum severity level of messages to log.
log_file: str, default: ""

@@ -123,3 +114,3 @@ File name to write log messages to.

Flag indicating if beta features are enabled.
linux_install_path: os.PathLike, None, default: None
linux_install_path: os.PathLike, None default: None
Path to the Ansys installation directory on Linux. This parameter is only

@@ -130,3 +121,4 @@ required when Ansys has not been installed in the default location. Example:

transport_mode : TransportMode | str, default: TransportMode.UDS
The transport mode to use for the connection. Can be a member of the :class:`TransportMode <.constants.TransportMode>` enum or a string
The transport mode to use for the connection. Can be a member of the :class:`TransportMode
<.constants.TransportMode>` enum or a string
('insecure', 'mtls', or 'uds').

@@ -158,7 +150,7 @@ certs_dir : Path | str | None

Start and connect to a server on localhost or in a
`PyPIM`_-enabled cloud environment. Allow two simultaneous
simulations on the server.
Start and connect to two servers on localhost or in a
`PyPIM`_-enabled cloud environment. Allow each server to run two
simultaneous simulations.
>>> additive = Additive(nsims_per_server=2)
>>> additive = Additive(nsims_per_server=2, nservers=2)

@@ -171,3 +163,2 @@ Start a single server on localhost or in a `PyPIM`_-enabled cloud environment.

.. _PyPIM: https://pypim.docs.pyansys.com/version/stable/index.html
"""

@@ -179,8 +170,9 @@

self,
channel: grpc.Channel | None = None,
server_connections: list[str | grpc.Channel] = None,
host: str | None = None,
port: int = DEFAULT_ADDITIVE_SERVICE_PORT,
nsims_per_server: int = 1,
nservers: int = 1,
product_version: str = DEFAULT_PRODUCT_VERSION,
log_level: str = "",
log_level: str = "INFO",
log_file: str = "",

@@ -196,16 +188,15 @@ enable_beta_features: bool = False,

"""Initialize server connections."""
if not product_version:
if product_version is None or product_version == "":
product_version = DEFAULT_PRODUCT_VERSION
if log_level:
LOG.setLevel(log_level)
if log_file:
LOG.log_to_file(filename=log_file, level=log_level)
self._log = Additive._create_logger(log_file, log_level)
self._log.debug("Logging set to %s", log_level)
self._server = Additive._connect_to_server(
channel,
self._servers = Additive._connect_to_servers(
server_connections,
host,
port,
nservers,
product_version,
LOG,
self._log,
linux_install_path,

@@ -218,10 +209,3 @@ transport_mode,

)
# HACK: Set the number of concurrent simulations per server
# when generating documentation to reduce time.
if os.getenv("GENERATING_DOCS", None):
nsims_per_server = 8
initial_settings = {"NumConcurrentSims": str(nsims_per_server)}
LOG.info(self.apply_server_settings(initial_settings))
self._nsims_per_server = nsims_per_server
self._enable_beta_features = enable_beta_features

@@ -236,6 +220,29 @@

@staticmethod
def _connect_to_server(
channel: grpc.Channel | None = None,
def _create_logger(log_file, log_level) -> logging.Logger:
"""Instantiate the logger."""
format = "%(asctime)s %(message)s"
datefmt = "%Y-%m-%d %H:%M:%S"
numeric_level = getattr(logging, log_level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError("Invalid log level: %s" % log_level)
logging.basicConfig(
level=numeric_level,
format=format,
datefmt=datefmt,
)
log = logging.getLogger(__name__)
if log_file:
file_handler = logging.FileHandler(str(log_file))
file_handler.setLevel(numeric_level)
file_handler.setFormatter(logging.Formatter(format))
log.file_handler = file_handler
log.addHandler(file_handler)
return log
@staticmethod
def _connect_to_servers(
server_connections: list[str | grpc.Channel] = None,
host: str | None = None,
port: int = DEFAULT_ADDITIVE_SERVICE_PORT,
nservers: int = 1,
product_version: str = DEFAULT_PRODUCT_VERSION,

@@ -249,21 +256,25 @@ log: logging.Logger | None = None,

allow_remote_host: bool = False,
) -> ServerConnection:
"""Connect to an Additive server, starting it if necessary.
) -> list[ServerConnection]:
"""Connect to Additive servers. Start them if necessary.
Parameters
----------
channel: grpc.Channel, default: None
Server connection. If provided, it is assumed to be connected
and the ``host`` and ``port`` parameters are ignored.
server_connections: list[str, grpc.Channel], None
List of connection definitions for servers. The list may be a combination of strings and
connected :class:`grpc.Channel <grpc.Channel>` objects. Strings use the format
"host:port".
host: str, default: None
Host name or IPv4 address of the server. This parameter is ignored if
the ``channel`` parameter is other than ``None``.
Host name or IPv4 address of the server. This parameter is ignored if the
``server_channels`` or ``channel`` parameters is other than ``None``.
port: int, default: 50052
Port number to use when connecting to the server.
nservers: int, default: 1
Number of servers to connect to or start if necessary.
product_version: str
Version of the Ansys product installation in the form ``"YYR"``, where ``YY``
is the two-digit year and ``R`` is the release number. For example, "251".
This parameter is only applicable in `PyPIM`_-enabled cloud environments and
on localhost. Using an empty string or ``None`` uses the default product version.
log: logging.Logger, default: None
Version of the Ansys product installation in the form "YYR", where "YY" is the
two-digit year and "R" is the release number. For example, the release 2024 R1
would be specified as "241". This parameter is only applicable in `PyPIM`-enabled
cloud environments and on localhost. Using an empty string or ``None`` uses the
default product version.
log: logging.Logger, None
Logger to use for logging messages.

@@ -276,3 +287,4 @@ linux_install_path: os.PathLike, None, default: None

transport_mode : TransportMode | str
The transport mode to use for the connection. Can be a member of the :class:`TransportMode <.constants.TransportMode>` enum or a string
The transport mode to use for the connection. Can be a member of the :class:`TransportMode
<.constants.TransportMode>` enum or a string
('insecure', 'mtls', or 'uds').

@@ -290,48 +302,74 @@ certs_dir : Path | str | None

-------
ServerConnection
Connection to the server.
NOTE: If ``channel`` and ``host`` are not provided and the environment variable
``ANSYS_ADDITIVE_ADDRESS`` is set, the client will connect to the server at the
address specified by the environment variable. The value of the environment variable
should be in the form ``host:port``.
list[ServerConnection]
List of connected servers.
"""
if channel:
if not isinstance(channel, grpc.Channel):
raise ValueError("channel must be a grpc.Channel object")
return ServerConnection(channel=channel, log=log, allow_remote_host=allow_remote_host)
connections = []
if server_connections:
for target in server_connections:
if isinstance(target, grpc.Channel):
connections.append(
ServerConnection(
channel=target,
log=log,
allow_remote_host=allow_remote_host,
)
)
else:
connections.append(
ServerConnection(addr=target, log=log, allow_remote_host=allow_remote_host)
)
elif host:
return ServerConnection(
addr=f"{host}:{port}",
log=log,
transport_mode=transport_mode,
certs_dir=certs_dir,
uds_dir=uds_dir,
uds_id=uds_id,
allow_remote_host=allow_remote_host,
connections.append(
ServerConnection(
addr=f"{host}:{port}",
log=log,
transport_mode=transport_mode,
certs_dir=certs_dir,
uds_dir=uds_dir,
uds_id=uds_id,
allow_remote_host=allow_remote_host,
)
)
elif os.getenv("ANSYS_ADDITIVE_ADDRESS"):
return ServerConnection(
addr=os.getenv("ANSYS_ADDITIVE_ADDRESS"),
log=log,
transport_mode=transport_mode,
certs_dir=certs_dir,
uds_dir=uds_dir,
uds_id=uds_id,
allow_remote_host=allow_remote_host,
connections.append(
ServerConnection(
addr=os.getenv("ANSYS_ADDITIVE_ADDRESS"),
log=log,
transport_mode=transport_mode,
certs_dir=certs_dir,
uds_dir=uds_dir,
uds_id=uds_id,
allow_remote_host=allow_remote_host,
)
)
else:
return ServerConnection(
product_version=product_version,
log=log,
linux_install_path=linux_install_path,
transport_mode=transport_mode,
certs_dir=certs_dir,
uds_dir=uds_dir,
uds_id=uds_id,
allow_remote_host=allow_remote_host,
)
for _ in range(nservers):
connections.append(
ServerConnection(
product_version=product_version,
log=log,
linux_install_path=linux_install_path,
transport_mode=transport_mode,
certs_dir=certs_dir,
uds_dir=uds_dir,
uds_id=uds_id,
allow_remote_host=allow_remote_host,
)
)
return connections
@property
def nsims_per_server(self) -> int:
"""Number of simultaneous simulations to run on each server."""
return self._nsims_per_server
@nsims_per_server.setter
def nsims_per_server(self, value: int) -> None:
"""Set the number of simultaneous simulations to run on each server."""
if value < 1:
raise ValueError("Number of simulations per server must be greater than zero.")
self._nsims_per_server = value
@property
def enable_beta_features(self) -> bool:

@@ -346,65 +384,17 @@ """Flag indicating if beta features are enabled."""

@property
def connected(self) -> bool:
"""Return True if the client is connected to a server."""
return self._server.status().connected
def about(self) -> None:
"""Print information about the client and server."""
print(f"Client {__version__}, API version: {api_version}")
if self._servers is None:
print("Client is not connected to a server.")
return
else:
for server in self._servers:
print(server.status())
@property
def uds_file(self) -> Path | None:
def uds_files(self) -> list[Path] | None:
"""Path to the Unix Domain Socket file if using 'uds' transport mode, otherwise None."""
return self._server.uds_file
return [server.uds_file for server in self._servers if server.uds_file is not None]
def about(self) -> str:
"""Return information about the client and server.
Returns
-------
str
Information about the client and server.
"""
about = (
f"ansys.additive.core version {__version__}\nClient side API version: {api_version}\n"
)
if self._server is None:
about += "Client is not connected to a server.\n"
else:
about += str(self._server.status()) + "\n"
return about
def apply_server_settings(self, settings: dict[str, str]) -> list[str]:
"""Apply settings to each server.
Current settings include:
- ``NumConcurrentSims``: number of concurrent simulations per server.
Parameters
----------
settings: dict[str, str]
Dictionary of settings to apply to the server.
Returns
-------
list[str]
List of messages from the server.
"""
request = SettingsRequest()
for setting_key, setting_value in settings.items():
setting = request.settings.add()
setting.key = setting_key
setting.value = setting_value
response = self._server.settings_stub.ApplySettings(request)
return response.messages
def list_server_settings(self) -> dict[str, str]:
"""Get a dictionary of settings for the server."""
response = self._server.settings_stub.ListSettings(Empty())
settings = {}
for setting in response.settings:
settings[setting.key] = setting.value
return settings
def simulate(

@@ -434,3 +424,4 @@ self,

----------
inputs: SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput, Microstructure3DInput, list
inputs: SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput,
Microstructure3DInput, list
Parameters to use for simulations. A list of inputs may be provided to run multiple

@@ -448,56 +439,9 @@ simulations.

list is returned.
""" # noqa: E501
summaries = []
task_mgr = self.simulate_async(inputs, progress_handler)
task_mgr.wait_all(progress_handler=progress_handler)
summaries = task_mgr.summaries()
for summ in summaries:
if isinstance(summ, SimulationError):
LOG.error(f"\nError: {summ.message}")
return summaries if isinstance(inputs, list) else summaries[0]
def simulate_async(
self,
inputs: (
SingleBeadInput
| PorosityInput
| MicrostructureInput
| ThermalHistoryInput
| Microstructure3DInput
| list
),
progress_handler: IProgressHandler | None = None,
) -> SimulationTaskManager:
"""Execute additive simulations asynchronously. This method does not block while the
simulations are running on the server. This class stores handles of type
google.longrunning.Operation to the remote tasks that can be used to communicate with
the server for status updates.
Parameters
----------
inputs: SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput, Microstructure3DInput, list
Parameters to use for simulations. A list of inputs may be provided to run multiple
simulations.
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, and ``inputs`` contains a single
simulation input, a default progress handler will be assigned.
Returns
-------
SimulationTaskManager
A SimulationTaskManager to handle all tasks sent to the server by this function call.
""" # noqa: E501
"""
self._check_for_duplicate_id(inputs)
task_manager = SimulationTaskManager()
if not isinstance(inputs, list):
if not progress_handler:
progress_handler = DefaultSingleSimulationProgressHandler()
simulation_task = self._simulate(inputs, self._server, progress_handler)
task_manager.add_task(simulation_task)
return task_manager
return self._simulate(inputs, self._servers[0], progress_handler)

@@ -507,12 +451,35 @@ if len(inputs) == 0:

LOG.info(f"Starting {len(inputs)} simulations")
for sim_input in inputs:
task = self._simulate(sim_input, self._server, progress_handler)
task_manager.add_task(task)
summaries = []
LOG.info(
f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} Starting {len(inputs)} simulations",
end="",
)
threads = min(len(inputs), len(self._servers) * self._nsims_per_server)
with concurrent.futures.ThreadPoolExecutor(threads) as executor:
futures = []
for i, input in enumerate(inputs):
server_id = i % len(self._servers)
futures.append(
executor.submit(
self._simulate,
input=input,
server=self._servers[server_id],
progress_handler=progress_handler,
)
)
for future in concurrent.futures.as_completed(futures):
summary = future.result()
if isinstance(summary, SimulationError):
LOG.error(f"\nError: {summary.message}")
summaries.append(summary)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
LOG.info(
f"\r{timestamp} Completed {len(summaries)} of {len(inputs)} simulations",
end="",
)
return summaries
return task_manager
def _simulate(
self,
simulation_input: (
input: (
SingleBeadInput

@@ -526,3 +493,10 @@ | PorosityInput

progress_handler: IProgressHandler | None = None,
) -> SimulationTask:
) -> (
SingleBeadSummary
| PorositySummary
| MicrostructureSummary
| ThermalHistorySummary
| Microstructure3DSummary
| SimulationError
):
"""Execute a single simulation.

@@ -532,6 +506,9 @@

----------
simulation_input: SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput, Microstructure3DInput
input: SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput,
Microstructure3DInput
Parameters to use for simulation.
server: ServerConnection
Server to use for the simulation.
progress_handler: IProgressHandler, None, default: None

@@ -542,41 +519,45 @@ Handler for progress updates. If ``None``, no progress updates are provided.

-------
SimulationTask
A task that can be used to monitor the simulation progress.
""" # noqa: E501
if simulation_input.material == AdditiveMaterial():
SingleBeadSummary, PorositySummary, MicrostructureSummary, ThermalHistorySummary,
Microstructure3DSummary, SimulationError
"""
if input.material == AdditiveMaterial():
raise ValueError("A material is not assigned to the simulation input")
if (
isinstance(simulation_input, (Microstructure3DInput, ThermalHistoryInput))
and self.enable_beta_features is False
):
if isinstance(input, Microstructure3DInput) and self.enable_beta_features is False:
raise BetaFeatureNotEnabledError(
"This simulation requires beta features to be enabled.\n"
"Set enable_beta_features=True when creating the Additive client."
"3D microstructure simulations require beta features to be enabled.\n"
+ "Set enable_beta_features=True when creating the Additive client."
)
try:
request = create_request(simulation_input, server, progress_handler)
long_running_op = server.simulation_stub.Simulate(request)
simulation_task = SimulationTask(
server, long_running_op, simulation_input, self._user_data_path
)
LOG.debug(f"Simulation task created for {simulation_input.id}")
request = None
if isinstance(input, ThermalHistoryInput):
return self._simulate_thermal_history(
input, USER_DATA_PATH, server, progress_handler
)
else:
request = input._to_simulation_request()
for response in server.simulation_stub.Simulate(request):
if response.HasField("progress"):
progress = Progress.from_proto_msg(input.id, response.progress)
if progress_handler:
progress_handler.update(progress)
if progress.state == ProgressState.ERROR:
raise Exception(progress.message)
if response.HasField("melt_pool"):
return SingleBeadSummary(input, response.melt_pool)
if response.HasField("porosity_result"):
return PorositySummary(input, response.porosity_result)
if response.HasField("microstructure_result"):
return MicrostructureSummary(
input, response.microstructure_result, self._user_data_path
)
if response.HasField("microstructure_3d_result"):
return Microstructure3DSummary(
input, response.microstructure_3d_result, self._user_data_path
)
except Exception as e:
metadata = OperationMetadata(simulation_id=simulation_input.id, message=str(e))
errored_op = Operation(name=simulation_input.id, done=True)
errored_op.metadata.Pack(metadata)
simulation_task = SimulationTask(
server, errored_op, simulation_input, self._user_data_path
)
return SimulationError(input, str(e))
if progress_handler:
time.sleep(0.1) # Allow time for the server to start the simulation
progress_handler.update(simulation_task.status())
return simulation_task
def materials_list(self) -> list[str]:

@@ -589,6 +570,5 @@ """Get a list of material names used in additive simulations.

Names of available additive materials.
"""
response = self._server.materials_stub.GetMaterialsList(Empty())
return response.names
response = self._servers[0].materials_stub.GetMaterialsList(Empty())
return [n for n in response.names]

@@ -600,2 +580,3 @@ def material(self, name: str) -> AdditiveMaterial:

----------
name: str

@@ -607,7 +588,5 @@ Name of material.

AdditiveMaterial
Requested material definition.
"""
request = GetMaterialRequest(name=name)
result = self._server.materials_stub.GetMaterial(request)
result = self._servers[0].materials_stub.GetMaterial(request)
return AdditiveMaterial._from_material_message(result)

@@ -617,8 +596,5 @@

def load_material(
parameters_file: str,
thermal_lookup_file: str,
characteristic_width_lookup_file: str,
parameters_file: str, thermal_lookup_file: str, characteristic_width_lookup_file: str
) -> AdditiveMaterial:
"""Load a custom material definition for the current session. The resulting
``AdditiveMaterial`` object will not be saved to the library.
"""Load a user-provided material definition.

@@ -643,8 +619,2 @@ Parameters

in the *Additive Manufacturing Beta Features* documentation.
Returns
-------
AdditiveMaterial
A material definition for use in additive simulations.
"""

@@ -657,66 +627,2 @@ material = AdditiveMaterial()

def add_material(
self,
parameters_file: str,
thermal_lookup_file: str,
characteristic_width_lookup_file: str,
) -> AdditiveMaterial | None:
"""Add a custom material to the library for use in additive simulations.
Parameters
----------
parameters_file: str
Name of the JSON file containing material parameters. For more information, see
`Create Material Configuration File (.json)
<https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/v232/en/add_beta/add_print_udm_tool_create_tables.html>`_
in the *Additive Manufacturing Beta Features* documentation.
thermal_lookup_file: str
Name of the CSV file containing the lookup table for temperature-dependent properties.
For more information, see `Create Material Lookup File (.csv)
<https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/v232/en/add_beta/add_print_udm_create_mat_lookup.html>`_
in the *Additive Manufacturing Beta Features* documentation.
characteristic_width_lookup_file: str
Name of the CSV file containing the lookup table for characteristic melt pool width. For
more information, see
`Find Characteristic Width Values and Generate Characteristic Width File (.csv)
<https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/v232/en/add_beta/add_print_udm_tool_find_cw.html>`_
in the *Additive Manufacturing Beta Features* documentation.
Returns
-------
AdditiveMaterial
A definition of the material that was added to the library.
""" # noqa: E501
material = self.load_material(
parameters_file, thermal_lookup_file, characteristic_width_lookup_file
)
names = self.materials_list()
if material.name.lower() in (name.lower() for name in names):
raise ValueError(f"Material {material.name} already exists. Unable to add material.")
request = AddMaterialRequest(id=misc.short_uuid(), material=material._to_material_message())
LOG.info(f"Adding material {request.material.name}")
response = self._server.materials_stub.AddMaterial(request)
if response.HasField("error"):
raise RuntimeError(response.error)
return AdditiveMaterial._from_material_message(response.material)
def remove_material(self, name: str):
"""Remove a material from the server.
Parameters
----------
name: str
Name of the material to remove.
"""
if name.lower() in (material.lower() for material in RESERVED_MATERIAL_NAMES):
raise ValueError(f"Unable to remove Ansys-supplied material '{name}'.")
self._server.materials_stub.RemoveMaterial(RemoveMaterialRequest(name=name))
def tune_material(

@@ -727,3 +633,3 @@ self,

progress_handler: IProgressHandler = None,
) -> MaterialTuningSummary | None:
) -> MaterialTuningSummary:
"""Tune a custom material for use with additive simulations.

@@ -751,40 +657,5 @@

-------
MaterialTuningSummary, None
Summary of material tuning or ``None`` if the tuning failed.
MaterialTuningSummary
Summary of material tuning.
""" # noqa: E501
task = self.tune_material_async(input, out_dir)
task.wait(progress_handler=progress_handler)
return task.summary
def tune_material_async(
self,
input: MaterialTuningInput,
out_dir: str = USER_DATA_PATH,
) -> SimulationTask:
"""Tune a custom material for use with additive simulations asynchronously.
This method performs the same function as the Material Tuning Tool
described in
`Find Simulation Parameters to Match Simulation to Experiments
<https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/v232/en/add_beta/add_print_udm_tool_match_sim_to_exp.html>`_.
It is used for one step in the material tuning process. The other steps
are described in
`Chapter 2: Material Tuning Tool (Beta) to Create User Defined Materials
<https://ansyshelp.ansys.com/account/secured?returnurl=/Views/Secured/corp/v232/en/add_beta/add_science_BETA_material_tuning_tool.html>`_.
Parameters
----------
input: MaterialTuningInput
Input parameters for material tuning.
out_dir: str, default: USER_DATA_PATH
Folder path for output files.
Returns
-------
SimulationTask
An asynchronous simulation task.
"""
if input.id == "":

@@ -802,127 +673,107 @@ input.id = misc.short_uuid()

operation = self._server.materials_stub.TuneMaterial(request)
for response in self._servers[0].materials_stub.TuneMaterial(request):
if response.HasField("progress"):
progress = Progress.from_proto_msg(input.id, response.progress)
if progress.state == ProgressState.ERROR:
raise Exception(progress.message)
else:
for m in progress.message.splitlines():
if (
"License successfully" in m
or "Starting ThermalSolver" in m
or "threads for solver" in m
):
continue
LOG.info(m)
if progress_handler:
progress_handler.update(progress)
if response.HasField("result"):
return MaterialTuningSummary(input, response.result, out_dir)
return SimulationTask(self._server, operation, input, out_dir)
def __file_upload_reader(
self, file_name: str, chunk_size=2 * 1024**2
) -> Iterator[UploadFileRequest]:
"""Read a file and return an iterator of UploadFileRequests."""
file_size = os.path.getsize(file_name)
short_name = os.path.basename(file_name)
with open(file_name, mode="rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield UploadFileRequest(
name=short_name,
total_size=file_size,
content=chunk,
content_md5=hashlib.md5(chunk).hexdigest(),
)
def simulate_study(
def _simulate_thermal_history(
self,
study: ParametricStudy,
simulation_ids: list[str] | None = None,
types: list[SimulationType] | None = None,
priority: int | None = None,
iteration: int = None,
):
"""Run the simulations in a parametric study.
input: ThermalHistoryInput,
out_dir: str,
server: ServerConnection,
progress_handler: IProgressHandler | None = None,
) -> ThermalHistorySummary:
"""Run a thermal history simulation.
Parameters
----------
study : ParametricStudy
Parametric study to run.
simulation_ids : list[str], default: None
List of simulation IDs to run. If this value is ``None``,
all simulations with a status of ``Pending`` are run.
types : list[SimulationType], default: None
Type of simulations to run. If this value is ``None``,
all simulation types are run.
priority : int, default: None
Priority of simulations to run. If this value is ``None``,
all priorities are run.
iteration : int, default: None
Iteration number of simulations to run. The default is ``None``,
all iterations are run.
progress_handler : IProgressHandler, None, default: None
Handler for progress updates. If ``None``, a :class:`ParametricStudyProcessHandler` will be used.
Parameters
----------
input: ThermalHistoryInput
Simulation input parameters.
out_dir: str
Folder path for output files.
server: ServerConnection
Server to use for the simulation.
progress_handler: IPorgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
.
Returns
-------
:class:`ThermalHistorySummary`
"""
SLEEP_INTERVAL = 2
progress_handler = ParametricStudyProgressHandler(study)
num_summaries = 0
if input.geometry is None or input.geometry.path == "":
raise ValueError("The geometry path is not defined in the simulation input")
try:
task_mgr = self.simulate_study_async(
study, simulation_ids, types, priority, iteration, progress_handler
)
# Allow time for the server to start the simulations
time.sleep(SLEEP_INTERVAL)
while not task_mgr.done:
task_mgr.status(progress_handler)
if len(task_mgr.summaries()) > num_summaries:
# TODO: Only update the study with new summaries
study.update(task_mgr.summaries())
num_summaries = len(task_mgr.summaries())
time.sleep(SLEEP_INTERVAL)
remote_geometry_path = ""
for response in server.simulation_stub.UploadFile(
self.__file_upload_reader(input.geometry.path)
):
remote_geometry_path = response.remote_file_name
progress = Progress.from_proto_msg(input.id, response.progress)
if progress_handler:
progress_handler.update(progress)
if progress.state == ProgressState.ERROR:
raise Exception(progress.message)
except Exception as e:
LOG.error(f"Error running study: {e}")
study.reset_simulation_status()
raise RuntimeError from e
request = input._to_simulation_request(remote_geometry_path=remote_geometry_path)
for response in server.simulation_stub.Simulate(request):
if response.HasField("progress"):
progress = Progress.from_proto_msg(input.id, response.progress)
if progress_handler:
progress_handler.update(progress)
if progress.state == ProgressState.ERROR and "WARN" not in progress.message:
raise Exception(progress.message)
if response.HasField("thermal_history_result"):
path = os.path.join(out_dir, input.id, "coax_ave_output")
local_zip = download_file(
server.simulation_stub,
response.thermal_history_result.coax_ave_zip_file,
path,
)
with zipfile.ZipFile(local_zip, "r") as zip:
zip.extractall(path)
os.remove(local_zip)
return ThermalHistorySummary(input, path)
def simulate_study_async(
self,
study: ParametricStudy,
simulation_ids: list[str] | None = None,
types: list[SimulationType] | None = None,
priority: int | None = None,
iteration: int = None,
progress_handler: IProgressHandler = None,
) -> SimulationTaskManager:
"""Run the simulations in a parametric study asynchronously.
Parameters
----------
study : ParametricStudy
Parametric study to run.
simulation_ids : list[str], default: None
List of simulation IDs to run. If this value is ``None``,
all simulations with a status of ``Pending`` are run.
types : list[SimulationType], default: None
Type of simulations to run. If this value is ``None``,
all simulation types are run.
priority : int, default: None
Priority of simulations to run. If this value is ``None``,
all priorities are run.
iteration : int, default: None
Iteration number of simulations to run. The default is ``None``,
all iterations are run.
progress_handler : IProgressHandler, None, default: None
Handler for progress updates.
"""
inputs = study.simulation_inputs(self.material, simulation_ids, types, priority, iteration)
if not inputs:
# no simulations met the provided criteria, return an empty task manager
return SimulationTaskManager()
ids = [i.id for i in inputs]
study.set_simulation_status(ids, SimulationStatus.PENDING)
study.clear_errors(ids)
return self.simulate_async(inputs, progress_handler)
def _check_for_duplicate_id(self, inputs):
"""Check for duplicate simulation IDs in a list of inputs.
If an input does not have an ID, one will be assigned.
Parameters
----------
inputs : SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput, Microstructure3DInput, list
A simulation input or a list of simulation inputs.
Raises
------
ValueError
If a duplicate ID is found in the list of inputs.
""" # noqa: E501
if not isinstance(inputs, list):
# An individual input, not a list
if inputs.id == "":
inputs.id = misc.short_uuid()
return
ids = []
for i in inputs:
if not i.id:
# give input an id if none provided
i.id = misc.short_uuid()
if any(id == i.id for id in ids):
raise ValueError(f'Duplicate simulation ID "{i.id}" in input list')
ids.append(i.id)
for input in inputs:
if input.id == "":
input.id = misc.short_uuid()
if input.id in ids:
raise ValueError(f'Duplicate simulation ID "{input.id}" in input list')
ids.append(input.id)

@@ -23,3 +23,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Unit conversion constants and functions."""
MM_TO_METER = 0.001

@@ -41,3 +40,2 @@ METER_TO_MM = 1000

Equivalent degrees in kelvin.
"""

@@ -59,4 +57,3 @@ return celsius + 273.15

Equivalent degrees in celsius.
"""
return kelvin - 273.15

@@ -23,15 +23,11 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides a function for downloading files from the server to the client."""
import hashlib
import os
from ansys.additive.core.progress_handler import (
IProgressHandler,
Progress,
ProgressState,
)
from ansys.api.additive.v0.additive_simulation_pb2 import DownloadFileRequest
from ansys.api.additive.v0.additive_simulation_pb2_grpc import SimulationServiceStub
from ansys.additive.core.progress_handler import IProgressHandler, Progress, ProgressState
def download_file(

@@ -47,4 +43,2 @@ stub: SimulationServiceStub,

----------
stub: SimulationServiceStub
gRPC stub for the simulation service.
remote_file_name: str

@@ -61,3 +55,2 @@ Path to file on the server.

Local path of downloaded file.
"""

@@ -78,3 +71,3 @@

if len(response.content) > 0:
md5 = hashlib.md5(response.content).hexdigest() # noqa: S324
md5 = hashlib.md5(response.content).hexdigest()
if md5 != response.content_md5:

@@ -81,0 +74,0 @@ msg = "Download error, MD5 sums did not match"

@@ -23,3 +23,3 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides functions for downloading sample datasets from the PyAdditive repository."""
from http.client import HTTPMessage
import os

@@ -29,3 +29,2 @@ import shutil

import zipfile
from http.client import HTTPMessage

@@ -68,3 +67,2 @@ from ansys.additive.core import EXAMPLES_PATH

Path to the decompressed contents of the ZIP file.
"""

@@ -96,3 +94,2 @@ outdir = EXAMPLES_PATH

Name for the example data file.
Returns

@@ -102,3 +99,2 @@ -------

HttpMessage: HTTP status message, if any.
"""

@@ -147,3 +143,2 @@ # First check if file has already been downloaded

Path to the characteristic width lookup file (CSV).
"""

@@ -220,3 +215,2 @@

Path to the characteristic width lookup file.
"""

@@ -223,0 +217,0 @@

@@ -27,1 +27,3 @@ # Copyright (C) 2024 ANSYS, Inc. and/or its affiliates.

"""Raised when a beta feature is not enabled."""
pass

@@ -23,5 +23,4 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides a container for part geometry files."""
from enum import IntEnum
import os
from enum import IntEnum

@@ -66,3 +65,2 @@ from ansys.api.additive.v0.additive_domain_pb2 import BuildFileMachineType

Path to the build file.
"""

@@ -91,3 +89,6 @@

return False
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(other, k):
return False
return True

@@ -129,3 +130,2 @@ @property

Path to file.
"""

@@ -145,3 +145,6 @@ if not os.path.exists(path):

return False
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(other, k):
return False
return True

@@ -148,0 +151,0 @@ @property

@@ -70,8 +70,5 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""
import logging
import sys
from IPython import get_ipython
# Default logging configuration

@@ -84,24 +81,4 @@ LOG_LEVEL = logging.DEBUG

FILE_MSG_FORMAT = STDOUT_MSG_FORMAT
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def is_notebook() -> bool:
"""Check if the code is running in a Jupyter notebook.
Returns:
bool: True if running in a Jupyter notebook, False otherwise.
"""
try:
shell = get_ipython().__class__.__name__
if shell == "ZMQInteractiveShell":
return True
elif shell == "TerminalInteractiveShell":
return False
else:
return False
except NameError:
return False
class PyAdditivePercentStyle(logging.PercentStyle):

@@ -118,3 +95,6 @@ """Provides a common messaging style for the ``PyAdditiveFormatter`` class."""

defaults = self._defaults
values = defaults | record.__dict__ if defaults else record.__dict__
if defaults:
values = defaults | record.__dict__
else:
values = record.__dict__

@@ -167,7 +147,6 @@ # We can make any changes that we want in the record here. For example,

Name of the file to write log log messages to.
"""
file_handler = None
stdout_handler = None
std_out_handler = None
_level = logging.DEBUG

@@ -181,10 +160,5 @@ _instances = {}

if is_notebook():
level = logging.INFO
# create default main logger
self.logger = logging.getLogger("PyAdditive_global")
self.logger.setLevel(level)
self.logger.propagate = True

@@ -219,3 +193,2 @@

Logging level to filter the message severity allowed in the logger.
"""

@@ -232,3 +205,2 @@

Logging level to filter the message severity allowed in the logger.
"""

@@ -245,3 +217,2 @@

Name of the logger.
"""

@@ -257,19 +228,3 @@

def setLevel(self, level: str | int) -> None:
"""Set the logging level for the logger.
Parameters
----------
level : str or int
Logging level to filter the message severity allowed in the logger.
If int, it must be one of the levels defined in the :obj:`~logging` module.
Valid string values are ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, ``"ERROR"``,
and ``"CRITICAL"``.
"""
self.logger.setLevel(level)
for handler in self.logger.handlers:
handler.setLevel(level)
def addfile_handler(logger, filename=FILE_NAME, level=LOG_LEVEL):

@@ -291,3 +246,2 @@ """Add a file handler to the input.

:class:`Logger` or :class:`logging.Logger` object.
"""

@@ -323,14 +277,13 @@ file_handler = logging.FileHandler(filename)

:class:`Logger` or :class:`logging.Logger` object.
"""
stdout_handler = logging.StreamHandler()
stdout_handler.setLevel(level)
stdout_handler.setFormatter(PyAdditiveFormatter(STDOUT_MSG_FORMAT, DATE_FORMAT))
std_out_handler = logging.StreamHandler()
std_out_handler.setLevel(level)
std_out_handler.setFormatter(PyAdditiveFormatter(STDOUT_MSG_FORMAT))
if isinstance(logger, Logger):
logger.stdout_handler = stdout_handler
logger.logger.addHandler(stdout_handler)
logger.std_out_handler = std_out_handler
logger.logger.addHandler(std_out_handler)
elif isinstance(logger, logging.Logger):
logger.addHandler(stdout_handler)
logger.addHandler(std_out_handler)

@@ -337,0 +290,0 @@ return logger

@@ -23,9 +23,9 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides a container for machine parameters."""
import math
import ansys.additive.core.conversions as conversions
from ansys.api.additive.v0.additive_domain_pb2 import MachineSettings as MachineMessage
import ansys.additive.core.conversions as conversions
class MachineConstants:

@@ -137,3 +137,6 @@ """Provides constants for additive manufacturing machine settings."""

return False
return all(getattr(self, k) == getattr(__o, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(__o, k):
return False
return True

@@ -157,6 +160,3 @@ def __validate_range(self, value, min, max, name):

self.__validate_range(
value,
MachineConstants.MIN_LASER_POWER,
MachineConstants.MAX_LASER_POWER,
"laser_power",
value, MachineConstants.MIN_LASER_POWER, MachineConstants.MAX_LASER_POWER, "laser_power"
)

@@ -176,6 +176,3 @@ self._laser_power = value

self.__validate_range(
value,
MachineConstants.MIN_SCAN_SPEED,
MachineConstants.MAX_SCAN_SPEED,
"scan_speed",
value, MachineConstants.MIN_SCAN_SPEED, MachineConstants.MAX_SCAN_SPEED, "scan_speed"
)

@@ -326,4 +323,3 @@ self._scan_speed = value

"""Create an additive machine from a machine message received from the
Additive service.
"""
Additive service."""
if isinstance(msg, MachineMessage):

@@ -346,4 +342,3 @@ return AdditiveMachine(

"""Create a machine message from the additive machine to send to the
Additive service.
"""
Additive service."""
return MachineMessage(

@@ -350,0 +345,0 @@ laser_power=self.laser_power,

@@ -23,6 +23,4 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides input and result summary containers for material tuning."""
import os
from ansys.additive.core.simulation_input_base import SimulationInputBase
from ansys.api.additive.v0.additive_domain_pb2 import (

@@ -37,3 +35,3 @@ MaterialTuningInput as MaterialTuningInputMessage,

class MaterialTuningInput(SimulationInputBase):
class MaterialTuningInput:
"""Provides input parameters for tuning a custom material.

@@ -43,2 +41,4 @@

----------
id: str
ID for this set of tuning simulations.
experiment_data_file: str

@@ -65,3 +65,2 @@ Name of the CSV file containing the experimental results data.

This value is ignored otherwise. The default is ``353.15`` K, which is 80 C.
"""

@@ -72,2 +71,3 @@

*,
id: str,
experiment_data_file: str,

@@ -82,3 +82,2 @@ material_configuration_file: str,

"""Initialize a MaterialTuningInput object."""
super().__init__()

@@ -95,2 +94,3 @@ if not os.path.isfile(experiment_data_file):

raise FileNotFoundError(f"File not found: {characteristic_width_lookup_file}")
self.id = id
self.allowable_error = allowable_error

@@ -125,6 +125,3 @@ self.max_iterations = max_iterations

for k in self.__dict__:
if k == "_id":
repr += "id: " + str(self.id) + "\n"
else:
repr += k + ": " + str(getattr(self, k)) + "\n"
repr += k + ": " + str(getattr(self, k)) + "\n"
return repr

@@ -135,3 +132,6 @@

return False
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(other, k):
return False
return True

@@ -152,4 +152,2 @@

self._optimized_parameters_file = os.path.join(out_dir, "optimized_parameters.csv")
self._coefficients_file = None
self._material_configuration_file = None
self._characteristic_width_file = None

@@ -161,13 +159,3 @@ self._log_file = None

if msg.coefficients:
self._coefficients_file = os.path.join(out_dir, "coefficients.csv")
with open(self._coefficients_file, "wb") as f:
f.write(msg.coefficients)
if msg.material_parameters:
self._material_configuration_file = os.path.join(out_dir, "material_configuration.json")
with open(self._material_configuration_file, "wb") as f:
f.write(msg.material_parameters)
if msg.characteristic_width_lookup:
if len(msg.characteristic_width_lookup) > 0:
self._characteristic_width_file = os.path.join(

@@ -178,6 +166,6 @@ out_dir, "characteristic_width_lookup.csv"

f.write(msg.characteristic_width_lookup)
elif input.characteristic_width_lookup_file:
elif len(input.characteristic_width_lookup_file) > 0:
self._characteristic_width_file = input.characteristic_width_lookup_file
if msg.log:
if len(msg.log) > 0:
self._log_file = os.path.join(out_dir, "log.txt")

@@ -198,17 +186,3 @@ with open(self._log_file, "wb") as f:

@property
def coefficients_file(self) -> str | None:
"""Path to the calculated coefficients file."""
return self._coefficients_file
@property
def material_configuration_file(self) -> str | None:
"""Path to the updated material properties file.
Penetration depth and absorptivity coefficients are updated based on the tuning
results.
"""
return self._material_configuration_file
@property
def characteristic_width_file(self) -> str | None:
def characteristic_width_file(self) -> str:
"""Path to the characteristic width file or ``None``."""

@@ -215,0 +189,0 @@ return self._characteristic_width_file

@@ -23,3 +23,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides a container for material parameters."""
import collections

@@ -31,5 +30,2 @@ import csv

from ansys.api.additive.v0.additive_domain_pb2 import (
AdditiveMaterial as MaterialMessage,
)
from ansys.api.additive.v0.additive_domain_pb2 import (
CharacteristicWidthDataPoint as CharacteristicWidthDataPointMessage,

@@ -40,15 +36,5 @@ )

)
from ansys.api.additive.v0.additive_domain_pb2 import AdditiveMaterial as MaterialMessage
RESERVED_MATERIAL_NAMES = [
"17-4PH",
"316L",
"Al357",
"AlSi10Mg",
"CoCr",
"IN625",
"IN718",
"Ti64",
]
class CharacteristicWidthDataPoint:

@@ -64,7 +50,3 @@ """Provides the container for a characteristic width data point.

def __init__(
self,
*,
laser_power: float = 0,
scan_speed: float = 0,
characteristic_width: float = 0,
self, *, laser_power: float = 0, scan_speed: float = 0, characteristic_width: float = 0
):

@@ -85,3 +67,6 @@ """Initialize a characteristic width data point."""

return False
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(other, k):
return False
return True

@@ -91,4 +76,3 @@ @property

"""Characteristic melt pool width for a given laser power and scan
speed (m).
"""
speed (m)."""
return self._characteristic_width

@@ -128,8 +112,5 @@

@staticmethod
def _from_characteristic_width_data_point_message(
msg: CharacteristicWidthDataPointMessage,
):
def _from_characteristic_width_data_point_message(msg: CharacteristicWidthDataPointMessage):
"""Create a characteristic width data point`` from a characteristic
data point message received from the Additive service.
"""
data point message received from the Additive service."""
if not isinstance(msg, CharacteristicWidthDataPointMessage):

@@ -148,4 +129,3 @@ raise ValueError(

"""Create a characteristic width data point message from this
characteristic width data point to send to the Additive service.
"""
characteristic width data point to send to the Additive service."""
msg = CharacteristicWidthDataPointMessage()

@@ -196,3 +176,6 @@ for p in self.__dict__:

return False
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(other, k):
return False
return True

@@ -276,5 +259,3 @@ @property

@staticmethod
def _from_thermal_properties_data_point_message(
msg: ThermalPropertiesDataPointMessage,
):
def _from_thermal_properties_data_point_message(msg: ThermalPropertiesDataPointMessage):
"""Create a thermal properties data point from a thermal characteristic

@@ -323,3 +304,2 @@ data point message received from the Additive service.

anisotropic_strain_coefficient_z: float = 0,
description: str = "",
elastic_modulus: float = 0,

@@ -364,3 +344,2 @@ hardening_factor: float = 0,

self._anisotropic_strain_coefficient_z = anisotropic_strain_coefficient_z
self._description = description
self._elastic_modulus = elastic_modulus

@@ -416,3 +395,6 @@ self._hardening_factor = hardening_factor

return False
return all(getattr(self, k) == getattr(__o, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(__o, k):
return False
return True

@@ -510,12 +492,2 @@ @property

@property
def description(self) -> str:
"""Description of the material."""
return self._description
@description.setter
def description(self, value: str):
"""Set description."""
self._description = value
@property
def elastic_modulus(self) -> float:

@@ -797,4 +769,3 @@ """Elastic modulus (Pa)."""

"""Create an additive material from a material message received from
the Additive service.
"""
the Additive service."""
if not isinstance(msg, MaterialMessage):

@@ -818,4 +789,3 @@ raise ValueError("Invalid message object passed to from_material_message()")

"""Create a material message from the additive material to send to the
Additive service.
"""
Additive service."""
msg = MaterialMessage()

@@ -838,3 +808,2 @@ for p in self.__dict__:

self.name = data["name"]
self.description = data["description"]
parameters = data["configuration"]

@@ -841,0 +810,0 @@ # Convert camelCase to snake_case

@@ -23,10 +23,5 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides input and result summary containers for microstructure 3D simulations."""
import math
import os
from ansys.additive.core import misc
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.microstructure import Microstructure2DResult
from ansys.api.additive.v0.additive_domain_pb2 import (

@@ -38,3 +33,8 @@ Microstructure3DInput as Microstructure3DInputMessage,

from ansys.additive.core import misc
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.microstructure import _Microstructure2DResult
class Microstructure3DInput:

@@ -104,4 +104,4 @@ """Provides input parameters for 3D microstructure simulation.

num_initial_random_nuclei: int = DEFAULT_NUMBER_OF_RANDOM_NUCLEI,
machine: AdditiveMachine = None,
material: AdditiveMaterial = None,
machine: AdditiveMachine = AdditiveMachine(),
material: AdditiveMaterial = AdditiveMaterial(),
):

@@ -121,4 +121,4 @@ """Initialize a ``Microstructure3DInput`` object."""

self.num_initial_random_nuclei = num_initial_random_nuclei
self.machine = machine if machine else AdditiveMachine()
self.material = material if material else AdditiveMaterial()
self.machine = machine
self.material = material

@@ -206,6 +206,3 @@ def __repr__(self):

self.__validate_range(
value,
self.MIN_POSITION_COORDINATE,
self.MAX_POSITION_COORDINATE,
"sample_min_x",
value, self.MIN_POSITION_COORDINATE, self.MAX_POSITION_COORDINATE, "sample_min_x"
)

@@ -226,6 +223,3 @@ self._sample_min_x = value

self.__validate_range(
value,
self.MIN_POSITION_COORDINATE,
self.MAX_POSITION_COORDINATE,
"sample_min_y",
value, self.MIN_POSITION_COORDINATE, self.MAX_POSITION_COORDINATE, "sample_min_y"
)

@@ -246,6 +240,3 @@ self._sample_min_y = value

self.__validate_range(
value,
self.MIN_POSITION_COORDINATE,
self.MAX_POSITION_COORDINATE,
"sample_min_z",
value, self.MIN_POSITION_COORDINATE, self.MAX_POSITION_COORDINATE, "sample_min_z"
)

@@ -378,6 +369,3 @@ self._sample_min_z = value

def __init__(
self,
input: Microstructure3DInput,
result: Microstructure3DResult,
user_data_path: str,
self, input: Microstructure3DInput, result: Microstructure3DResult, user_data_path: str
) -> None:

@@ -398,3 +386,3 @@ """Initialize a ``Microstructure3DSummary`` object."""

f.write(result.three_d_vtk)
self._2d_result = Microstructure2DResult(result.two_d_result, outpath)
self._2d_result = _Microstructure2DResult(result.two_d_result, outpath)

@@ -401,0 +389,0 @@ def __repr__(self):

@@ -23,14 +23,5 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides input and result summary containers for microstructure simulations."""
import math
import os
import numpy as np
import pandas as pd
from google.protobuf.internal.containers import RepeatedCompositeFieldContainer
from ansys.additive.core import misc
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.simulation_input_base import SimulationInputBase
from ansys.api.additive.v0.additive_domain_pb2 import (

@@ -43,5 +34,12 @@ MicrostructureInput as MicrostructureInputMessage,

from ansys.api.additive.v0.additive_simulation_pb2 import SimulationRequest
from google.protobuf.internal.containers import RepeatedCompositeFieldContainer
import numpy as np
import pandas as pd
from ansys.additive.core import misc
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
class MicrostructureInput(SimulationInputBase):
class MicrostructureInput:
"""Provides input parameters for microstructure simulation.

@@ -109,2 +107,3 @@

self,
id: str = "",
*,

@@ -124,7 +123,6 @@ sample_min_x: float = DEFAULT_POSITION_COORDINATE,

random_seed: int = DEFAULT_RANDOM_SEED,
machine: AdditiveMachine = None,
material: AdditiveMaterial = None,
machine: AdditiveMachine = AdditiveMachine(),
material: AdditiveMaterial = AdditiveMaterial(),
):
"""Initialize a ``MicrostructureInput`` object."""
super().__init__()

@@ -154,2 +152,3 @@ # we have a circular dependency here, so we validate sensor_dimension

# use setters for remaining properties
self.id = id
self.sample_min_x = sample_min_x

@@ -163,4 +162,4 @@ self.sample_min_y = sample_min_y

self.melt_pool_depth = melt_pool_depth
self.machine = machine if machine else AdditiveMachine()
self.material = material if material else AdditiveMaterial()
self.machine = machine
self.material = material
if random_seed != self.DEFAULT_RANDOM_SEED:

@@ -219,2 +218,11 @@ self.random_seed = random_seed

@property
def id(self) -> str:
"""User-provided ID for this simulation."""
return self._id
@id.setter
def id(self, value: str):
self._id = value
@property
def machine(self):

@@ -249,6 +257,3 @@ """Machine-related parameters."""

self.__validate_range(
value,
self.MIN_POSITION_COORDINATE,
self.MAX_POSITION_COORDINATE,
"sample_min_x",
value, self.MIN_POSITION_COORDINATE, self.MAX_POSITION_COORDINATE, "sample_min_x"
)

@@ -269,6 +274,3 @@ self._sample_min_x = value

self.__validate_range(
value,
self.MIN_POSITION_COORDINATE,
self.MAX_POSITION_COORDINATE,
"sample_min_y",
value, self.MIN_POSITION_COORDINATE, self.MAX_POSITION_COORDINATE, "sample_min_y"
)

@@ -289,6 +291,3 @@ self._sample_min_y = value

self.__validate_range(
value,
self.MIN_POSITION_COORDINATE,
self.MAX_POSITION_COORDINATE,
"sample_min_z",
value, self.MIN_POSITION_COORDINATE, self.MAX_POSITION_COORDINATE, "sample_min_z"
)

@@ -364,6 +363,3 @@ self._sample_min_z = value

self.__validate_range(
value,
self.MIN_SENSOR_DIMENSION,
self.MAX_SENSOR_DIMENSION,
"sensor_dimension",
value, self.MIN_SENSOR_DIMENSION, self.MAX_SENSOR_DIMENSION, "sensor_dimension"
)

@@ -433,6 +429,3 @@ size_errors = ""

self.__validate_range(
value,
self.MIN_THERMAL_GRADIENT,
self.MAX_THERMAL_GRADIENT,
"thermal_gradient",
value, self.MIN_THERMAL_GRADIENT, self.MAX_THERMAL_GRADIENT, "thermal_gradient"
)

@@ -531,6 +524,3 @@ self._thermal_gradient = value

def __init__(
self,
input: MicrostructureInput,
result: MicrostructureResultMessage,
user_data_path: str,
self, input: MicrostructureInput, result: MicrostructureResultMessage, user_data_path: str
) -> None:

@@ -547,3 +537,3 @@ """Initialize a ``MicrostructureSummary`` object."""

outpath = os.path.join(user_data_path, id)
self._result = Microstructure2DResult(result, outpath)
self._result = _Microstructure2DResult(result, outpath)

@@ -655,3 +645,2 @@ def __repr__(self):

Average grain size (µm).
"""

@@ -665,3 +654,3 @@

class Microstructure2DResult:
class _Microstructure2DResult:
"""Provides the results of a 2D microstructure simulation."""

@@ -707,17 +696,2 @@

@property
def xy_average_grain_size(self) -> float:
"""Average grain size (µm) for the XY plane."""
return self._xy_average_grain_size
@property
def xz_average_grain_size(self) -> float:
"""Average grain size (µm) for the XZ plane."""
return self._xz_average_grain_size
@property
def yz_average_grain_size(self) -> float:
"""Average grain size (µm) for the YZ plane."""
return self._yz_average_grain_size
def __repr__(self):

@@ -724,0 +698,0 @@ repr = ""

@@ -23,3 +23,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides miscellaneous utility functions."""
import random

@@ -29,15 +28,5 @@ import string

def short_uuid(nchars: int = 12) -> str:
"""Generate a short UUID.
Parameters
----------
nchars : int, default 12
Number of characters in the UUID. Only applies if ``nchars`` is greater
than 6. Using the default, the probability
of a collision is 1.23e-17 for 1 billion rounds.
"""
alphabet = string.ascii_letters + string.digits
nchars = max(6, nchars)
return "".join(random.choices(alphabet, k=nchars)) # noqa: S311
def short_uuid() -> str:
"""Generate a short UUID."""
alphabet = string.ascii_lowercase + string.digits
return "".join(random.choices(alphabet, k=8))

@@ -23,3 +23,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides data storage and utility methods for a parametric study."""
from ansys.additive.core.parametric_study.constants import ( # noqa: F401

@@ -31,6 +30,4 @@ DEFAULT_ITERATION,

)
from ansys.additive.core.parametric_study.parametric_runner import ParametricRunner # noqa: F401
from ansys.additive.core.parametric_study.parametric_study import ParametricStudy # noqa: F401
from ansys.additive.core.parametric_study.parametric_study_progress_handler import ( # noqa: F401
ParametricStudyProgressHandler,
)
from ansys.additive.core.parametric_study.parametric_utils import ( # noqa: F401

@@ -37,0 +34,0 @@ build_rate,

@@ -55,4 +55,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Laser scan speed (m/s)."""
PV_RATIO = "Laser Power/Scan Speed (J/m)"
"""Ratio of laser power to scan speed (J/m)."""
START_ANGLE = "Start Angle (degrees)"

@@ -132,3 +130,3 @@ """Hatch scan angle for first layer (degrees)."""

"""Default priority assigned to new simulations."""
FORMAT_VERSION = 3
FORMAT_VERSION = 2
"""Parametric study file format version."""

@@ -24,4 +24,8 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

from typing import Optional
def build_rate(scan_speed: float, layer_thickness: float, hatch_spacing: float) -> float:
def build_rate(
scan_speed: float, layer_thickness: float, hatch_spacing: Optional[float] = None
) -> float:
"""Calculate the build rate.

@@ -39,3 +43,3 @@

Powder deposit layer thickness.
hatch_spacing : float
hatch_spacing : float, default: None
Distance between hatch scan lines.

@@ -46,7 +50,9 @@

float
Volumetric build rate is returned. If input units are m/s and m,
the output units are m^3/s.
Volumetric build rate is returned if hatch spacing is provided.
Otherwise, an area build rate is returned. If input units are m/s and m,
the output units are m^3/s or m^2/s.
"""
return round(scan_speed * layer_thickness * hatch_spacing, 16)
if hatch_spacing is None:
return scan_speed * layer_thickness
return scan_speed * layer_thickness * hatch_spacing

@@ -58,3 +64,3 @@

layer_thickness: float,
hatch_spacing: float,
hatch_spacing: Optional[float] = None,
) -> float:

@@ -75,3 +81,3 @@ """Calculate the energy density.

Powder deposit layer thickness.
hatch_spacing : float
hatch_spacing : float, default: None
Distance between hatch scan lines.

@@ -82,7 +88,7 @@

float
Volumetric energy density is returned. If input units are W, m/s, and m,
the output units are J/m^3.
Volumetric energy density is returned if hatch spacing is provided.
Otherwise an area energy density is returned. If input units are W, m/s, m, or m,
the output units are J/m^3 or J/m^2.
"""
br = build_rate(scan_speed, layer_thickness, hatch_spacing)
return laser_power / br if br else float("nan")

@@ -23,16 +23,13 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides input and result summary containers for porosity simulations."""
import math
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.simulation_input_base import SimulationInputBase
from ansys.api.additive.v0.additive_domain_pb2 import (
PorosityInput as PorosityInputMessage,
)
from ansys.api.additive.v0.additive_domain_pb2 import PorosityInput as PorosityInputMessage
from ansys.api.additive.v0.additive_domain_pb2 import PorosityResult
from ansys.api.additive.v0.additive_simulation_pb2 import SimulationRequest
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
class PorosityInput(SimulationInputBase):
class PorosityInput:
"""Provides input parameters for porosity simulation."""

@@ -49,2 +46,3 @@

self,
id: str = "",
*,

@@ -54,12 +52,12 @@ size_x: float = DEFAULT_SAMPLE_SIZE,

size_z: float = DEFAULT_SAMPLE_SIZE,
machine: AdditiveMachine = None,
material: AdditiveMaterial = None,
machine: AdditiveMachine = AdditiveMachine(),
material: AdditiveMaterial = AdditiveMaterial(),
):
"""Initialize a ``PorosityInput`` object."""
super().__init__()
self.id = id
self.size_x = size_x
self.size_y = size_y
self.size_z = size_z
self.machine = machine if machine else AdditiveMachine()
self.material = material if material else AdditiveMaterial()
self.machine = machine
self.material = material

@@ -94,2 +92,11 @@ def __repr__(self):

@property
def id(self) -> str:
"""User-provided ID for the simulation."""
return self._id
@id.setter
def id(self, value):
self._id = value
@property
def machine(self) -> AdditiveMachine:

@@ -96,0 +103,0 @@ """Machine-related parameters."""

@@ -28,17 +28,13 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

from ansys.api.additive.v0.additive_domain_pb2 import Progress as ProgressMsg
from ansys.api.additive.v0.additive_domain_pb2 import ProgressState as ProgressMsgState
from pydantic import BaseModel
from tqdm import tqdm
from ansys.api.additive.v0.additive_domain_pb2 import Progress as ProgressMsg
from ansys.api.additive.v0.additive_domain_pb2 import ProgressState as ProgressMsgState
from ansys.api.additive.v0.additive_operations_pb2 import OperationMetadata
class ProgressState(IntEnum):
"""Simulation progress status."""
NEW = ProgressMsgState.PROGRESS_STATE_NEW
"""Simulation created and not yet queued to run."""
WAITING = ProgressMsgState.PROGRESS_STATE_WAITING
"""Simulation is queued and waiting to start."""
"""Waiting for the simulation to start."""
RUNNING = ProgressMsgState.PROGRESS_STATE_EXECUTING

@@ -50,6 +46,2 @@ """Simulation is running."""

"""Simulation has errored."""
CANCELLED = ProgressMsgState.PROGRESS_STATE_CANCELLED
"""Simulation has been cancelled."""
WARNING = ProgressMsgState.PROGRESS_STATE_WARNING
"""Simulation completed with warnings."""

@@ -77,15 +69,4 @@

@classmethod
def from_operation_metadata(cls, metadata: OperationMetadata):
"""Create a ``Progress`` object from an operation metadata (long-running operations) protobuf message.""" # noqa: E501
return cls(
sim_id=metadata.simulation_id,
state=metadata.state,
percent_complete=metadata.percent_complete,
message=metadata.message,
context=metadata.context,
)
def __str__(self):
return f"{self.sim_id}: {self.state.name} - {self.percent_complete}% - {self.context} - {self.message}" # noqa: E501
return f"{self.id}: {self.state.name} - {self.percent_complete}% - {self.context} - {self.message}"

@@ -104,3 +85,2 @@

Progress information.
"""

@@ -117,3 +97,2 @@ raise NotImplementedError

Simulation ID.
"""

@@ -133,3 +112,2 @@

Latest progress.
"""

@@ -136,0 +114,0 @@ # Don't send progress when generating docs

@@ -24,3 +24,3 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

from ansys.additive.core.server_connection.constants import DEFAULT_PRODUCT_VERSION # noqa: F401
from ansys.additive.core.server_connection.server_connection import ServerConnection # noqa: F401
from ansys.additive.core.server_connection.constants import DEFAULT_PRODUCT_VERSION
from ansys.additive.core.server_connection.server_connection import ServerConnection

@@ -31,3 +31,3 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Product name for the Additive server in a PyPIM environment."""
DEFAULT_PRODUCT_VERSION = "251"
DEFAULT_PRODUCT_VERSION = "242"
"""Default Ansys product version to use for the Additive server."""

@@ -34,0 +34,0 @@ ADDITIVE_SERVER_EXE_NAME = "additiveserver"

@@ -23,9 +23,10 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides startup utilities for a local Additive server."""
from __future__ import annotations
from datetime import datetime
import os
from pathlib import Path
import socket
import subprocess
import time
from datetime import datetime
from pathlib import Path

@@ -72,3 +73,2 @@ from ansys.additive.core import USER_DATA_PATH

Server process. To stop the server, call the ``kill()`` method on the returned object.
"""

@@ -116,3 +116,3 @@ server_exe = ""

with open(os.path.join(cwd, f"additiveserver_{start_time}.log"), "w") as log_file:
server_process = subprocess.Popen( # noqa: S603
server_process = subprocess.Popen(
f'"{server_exe}" --port {port}',

@@ -154,3 +154,2 @@ shell=os.name != "nt", # use shell on Linux

This port may be taken by the time you try to use it.
"""

@@ -157,0 +156,0 @@ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

@@ -24,5 +24,7 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

from __future__ import annotations
import ipaddress
from pathlib import Path
import socket
from pathlib import Path

@@ -79,3 +81,4 @@ from ansys.tools.common.cyberchannel import create_channel as create_cyber_channel

transport_mode : TransportMode | str
The transport mode to use for the connection. Can be a member of the :class:`TransportMode <.constants.TransportMode>` enum or a string
The transport mode to use for the connection. Can be a member of the :class:`TransportMode
<.constants.TransportMode>` enum or a string
('insecure', 'mtls', or 'uds').

@@ -92,10 +95,9 @@ certs_dir : Path | str | None

Size, in bytes, of the buffer used to receive messages. Default is :obj:`MAX_RCV_MSG_LEN`.
Raises
------
ValueError
If the target string is improperly formed, the transport mode is invalid, or an unsupported transport mode is specified.
If the target string is improperly formed, the transport mode is invalid, or an unsupported
transport mode is specified.
ConnectionError
If unable to connect to the Unix Domain Socket when using 'uds' transport mode.
Returns

@@ -107,3 +109,2 @@ -------

Path to the Unix Domain Socket file if using 'uds' transport mode, otherwise None.
"""

@@ -132,17 +133,23 @@

)
return create_cyber_channel(
transport_mode="insecure",
host=host,
port=port_str,
grpc_options=[("grpc.max_receive_message_length", max_rcv_msg_len)],
), None
return (
create_cyber_channel(
transport_mode="insecure",
host=host,
port=port_str,
grpc_options=[("grpc.max_receive_message_length", max_rcv_msg_len)],
),
None,
)
case TransportMode.MTLS:
return create_cyber_channel(
transport_mode="mtls",
host=host,
port=port_str,
certs_dir=certs_dir,
grpc_options=[("grpc.max_receive_message_length", max_rcv_msg_len)],
), None
return (
create_cyber_channel(
transport_mode="mtls",
host=host,
port=port_str,
certs_dir=certs_dir,
grpc_options=[("grpc.max_receive_message_length", max_rcv_msg_len)],
),
None,
)

@@ -166,7 +173,8 @@ case TransportMode.UDS:

raise ConnectionError(
f"Could not connect to UDS socket in {uds_folder or 'None'} with id {uds_id or 'None'}."
f"Could not connect to UDS socket in {uds_folder or 'None'} with id "
f"{uds_id or 'None'}."
)
return uds_channel, Path(uds_channel._channel.target().decode().removeprefix("unix:")) # type: ignore
return uds_channel, Path(uds_channel._channel.target().decode().removeprefix("unix:"))
case _:
raise ValueError(f"Unsupported transport mode: {transport_mode}")

@@ -23,15 +23,18 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides definitions and untilities for connecting to the Additive server."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import os
from pathlib import Path
import signal
import time
from dataclasses import dataclass
from pathlib import Path
from ansys.api.additive.v0.about_pb2_grpc import AboutServiceStub
from ansys.api.additive.v0.additive_materials_pb2_grpc import MaterialsServiceStub
from ansys.api.additive.v0.additive_simulation_pb2_grpc import SimulationServiceStub
import ansys.platform.instancemanagement as pypim
from google.protobuf.empty_pb2 import Empty
import grpc
from google.longrunning.operations_pb2_grpc import OperationsStub
from google.protobuf.empty_pb2 import Empty
import ansys.platform.instancemanagement as pypim
from ansys.additive.core.server_connection.constants import (

@@ -45,6 +48,2 @@ DEFAULT_PRODUCT_VERSION,

from ansys.additive.core.server_connection.network_utils import create_channel
from ansys.api.additive.v0.about_pb2_grpc import AboutServiceStub
from ansys.api.additive.v0.additive_materials_pb2_grpc import MaterialsServiceStub
from ansys.api.additive.v0.additive_settings_pb2_grpc import SettingsServiceStub
from ansys.api.additive.v0.additive_simulation_pb2_grpc import SimulationServiceStub

@@ -64,3 +63,2 @@

Server metadata.
"""

@@ -75,12 +73,3 @@

def __str__(self) -> str:
if not self.connected:
return f"Server {self.channel_str} is not connected."
str = f"Server {self.channel_str} is connected."
if self.metadata:
for key, value in self.metadata.items():
str += f"\n {key}: {value}"
return str
class ServerConnection:

@@ -99,3 +88,4 @@ """Provides connection to Additive server.

transport_mode : TransportMode | str
The transport mode to use for the connection. Can be a member of the :class:`TransportMode <.constants.TransportMode>` enum or a string
The transport mode to use for the connection. Can be a member of the :class:`TransportMode
<.constants.TransportMode>` enum or a string
('insecure', 'mtls', or 'uds').

@@ -127,3 +117,2 @@ certs_dir : Path | str | None

version.
"""

@@ -146,3 +135,3 @@

if channel and addr:
if channel is not None and addr is not None:
raise ValueError("Both 'channel' and 'addr' cannot both be specified.")

@@ -170,8 +159,5 @@

self._server_process = LocalServer.launch(
port,
product_version=product_version,
linux_install_path=linux_install_path,
port, product_version=product_version, linux_install_path=linux_install_path
)
target = f"{LOCALHOST}:{port}"
# Save UDS parameters for disconnecting
channel_result, self._uds_file = create_channel(

@@ -186,4 +172,2 @@ target, transport_mode, certs_dir, uds_dir, uds_id, allow_remote_host

self._about_stub = AboutServiceStub(self._channel)
self._operations_stub = OperationsStub(self._channel)
self._settings_stub = SettingsServiceStub(self._channel)

@@ -195,11 +179,6 @@ if not self.ready():

def __del__(self) -> None:
"""Disconnect from server."""
self.disconnect()
def disconnect(self):
"""Clean up server connection."""
def __del__(self):
"""Destructor for cleaning up server connection."""
if hasattr(self, "_server_instance") and self._server_instance:
self._server_instance.delete()
self._server_instance = None
if hasattr(self, "_server_process") and self._server_process:

@@ -224,3 +203,2 @@ if os.name == "nt":

self._server_process.send_signal(signal.SIGINT)
self._server_process = None

@@ -230,2 +208,3 @@ @property

"""GRPC channel target.
The form is generally ``"ip:port"``. For example, ``"127.0.0.1:50052"``.

@@ -252,12 +231,2 @@ In the case of UDS channels, the form is the path to the UDS socket file.

@property
def operations_stub(self) -> OperationsStub:
"""Operations service stub."""
return self._operations_stub
@property
def settings_stub(self) -> SettingsServiceStub:
"""Settings service stub."""
return self._settings_stub
@property
def uds_file(self) -> Path | None:

@@ -296,3 +265,2 @@ """Path to the Unix Domain Socket file if using 'uds' transport mode, otherwise None."""

without receiving a response from the server.
"""

@@ -299,0 +267,0 @@ ready = False

@@ -23,4 +23,2 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides common definitions and classes for simulations."""
from enum import Enum
from typing import Union

@@ -34,3 +32,3 @@

class SimulationType(str, Enum):
class SimulationType:
"""Provides simulation types."""

@@ -46,23 +44,11 @@

class SimulationStatus(str, Enum):
class SimulationStatus:
"""Provides simulation status values."""
# NOTE: Values are listed in order of precedence when deduping.
# For example, if duplicate COMPLETED and WARNING simulations are
# found when removing duplicates, the COMPLETED simulation will be
# kept.
PENDING = "Pending"
"""Simulation is waiting to run."""
COMPLETED = "Completed"
"""Simulation completed successfully."""
WARNING = "Warning"
"""Simulation completed with warnings."""
"""Simulation was run."""
ERROR = "Error"
"""Simulation errored before completion."""
CANCELLED = "Cancelled"
"""Simulation was cancelled."""
RUNNING = "Running"
"""Simulation is running."""
PENDING = "Pending"
"""Simulation is queued and waiting to run."""
NEW = "New"
"""Simulation is created but not yet queued to run."""
"""Simulation errored."""
SKIP = "Skip"

@@ -69,0 +55,0 @@ """Do not run this simulation, only applies to parametric studies."""

@@ -23,9 +23,7 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides input and result summary containers for single bead simulations."""
import contextlib
import math
import os
import zipfile
import numpy as np
from ansys.api.additive.v0.additive_domain_pb2 import MeltPool as MeltPoolMessage
from ansys.api.additive.v0.additive_domain_pb2 import SingleBeadInput as SingleBeadInputMessage
from ansys.api.additive.v0.additive_simulation_pb2 import SimulationRequest
from pandas import DataFrame

@@ -35,11 +33,5 @@

from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.simulation_input_base import SimulationInputBase
from ansys.api.additive.v0.additive_domain_pb2 import MeltPool as MeltPoolMessage
from ansys.api.additive.v0.additive_domain_pb2 import (
SingleBeadInput as SingleBeadInputMessage,
)
from ansys.api.additive.v0.additive_simulation_pb2 import SimulationRequest
class SingleBeadInput(SimulationInputBase):
class SingleBeadInput:
"""Provides input parameters for a single bead simulation."""

@@ -53,27 +45,15 @@

"""Maximum bead length (m)."""
DEFAULT_OUTPUT_THERMAL_HISTORY = False
"""Default output thermal history flag."""
DEFAULT_THERMAL_HISTORY_INTERVAL = 1
"""Default thermal history interval."""
MIN_THERMAL_HISTORY_INTERVAL = 1
"""Minimum thermal history interval."""
MAX_THERMAL_HISTORY_INTERVAL = 10000
"""Maximum thermal history interval."""
def __init__(
self,
*,
id: str = "",
bead_length: float = DEFAULT_BEAD_LENGTH,
machine: AdditiveMachine = None,
material: AdditiveMaterial = None,
output_thermal_history: bool = DEFAULT_OUTPUT_THERMAL_HISTORY,
thermal_history_interval: int = DEFAULT_THERMAL_HISTORY_INTERVAL,
machine: AdditiveMachine = AdditiveMachine(),
material: AdditiveMaterial = AdditiveMaterial(),
):
"""Initialize a ``SingleBeadInput`` object."""
super().__init__()
self.id = id
self.bead_length = bead_length
self.machine = machine if machine else AdditiveMachine()
self.material = material if material else AdditiveMaterial()
self.output_thermal_history = output_thermal_history
self.thermal_history_interval = thermal_history_interval
self.machine = machine
self.material = material

@@ -97,4 +77,2 @@ def __repr__(self):

and self.material == __o.material
and self.output_thermal_history == __o.output_thermal_history
and self.thermal_history_interval == __o.thermal_history_interval
)

@@ -109,2 +87,11 @@

@property
def id(self) -> str:
"""User-provided ID for the simulation."""
return self._id
@id.setter
def id(self, value):
self._id = value
@property
def machine(self) -> AdditiveMachine:

@@ -141,30 +128,2 @@ """Machine parameters."""

@property
def output_thermal_history(self) -> bool:
"""Flag indicating whether to output the thermal history of the simulation."""
return self._output_thermal_history
@output_thermal_history.setter
def output_thermal_history(self, value: bool):
self._output_thermal_history = value
@property
def thermal_history_interval(self) -> int:
"""Interval, in simulation steps, between thermal history results.
Use ``1`` to create thermal history results for every simulation step,
``2`` for every other step, and so on.
"""
return self._thermal_history_interval
@thermal_history_interval.setter
def thermal_history_interval(self, value: int):
self.__validate_range(
value,
self.MIN_THERMAL_HISTORY_INTERVAL,
self.MAX_THERMAL_HISTORY_INTERVAL,
"thermal_history_interval",
)
self._thermal_history_interval = value
def _to_simulation_request(self) -> SimulationRequest:

@@ -176,4 +135,2 @@ """Convert this object into a simulation request message."""

bead_length=self.bead_length,
output_thermal_history=self.output_thermal_history,
thermal_history_interval=self.thermal_history_interval,
)

@@ -201,13 +158,4 @@ return SimulationRequest(id=self.id, single_bead_input=input)

def __init__(self, msg: MeltPoolMessage, thermal_history_output: str | None = None):
"""Initialize a ``MeltPool`` object.
Parameters
----------
msg: MeltPoolMessage
The message containing the melt pool data.
thermal_history_output: str | None
Path to the thermal history output file.
"""
def __init__(self, msg: MeltPoolMessage):
"""Initialize a ``MeltPool`` object."""
bead_length = [ts.laser_x for ts in msg.time_steps]

@@ -230,3 +178,2 @@ length = [ts.length for ts in msg.time_steps]

self._df.index.name = "bead_length"
self._thermal_history_output = thermal_history_output

@@ -250,34 +197,2 @@ def data_frame(self) -> DataFrame:

def depth_over_width(self) -> float:
"""Return the median reference depth over reference width."""
depth = self.median_reference_depth()
width = self.median_reference_width()
return depth / width if width != 0 else np.nan
def length_over_width(self) -> float:
"""Return the median length over width."""
length = self.median_length()
width = self.median_width()
return length / width if width != 0 else np.nan
def median_width(self) -> float:
"""Return the median width."""
return self._df[MeltPoolColumnNames.WIDTH].median()
def median_depth(self) -> float:
"""Return the median depth."""
return self._df[MeltPoolColumnNames.DEPTH].median()
def median_length(self) -> float:
"""Return the median length."""
return self._df[MeltPoolColumnNames.LENGTH].median()
def median_reference_width(self) -> float:
"""Return the median reference width."""
return self._df[MeltPoolColumnNames.REFERENCE_WIDTH].median()
def median_reference_depth(self) -> float:
"""Return the median reference depth."""
return self._df[MeltPoolColumnNames.REFERENCE_DEPTH].median()
def __eq__(self, __o: object) -> bool:

@@ -291,18 +206,8 @@ if not isinstance(__o, MeltPool):

repr += self._df.to_string()
repr += (
"\n" + "grid_full_thermal_sensor_file_output_path: " + str(self.thermal_history_output)
)
return repr
@property
def thermal_history_output(self) -> str | None:
"""Path to the thermal history output file."""
return self._thermal_history_output
class SingleBeadSummary:
"""Provides a summary of a single bead simulation."""
THERMAL_HISTORY_OUTPUT_ZIP = "gridfullthermal.zip"
def __init__(

@@ -312,3 +217,2 @@ self,

msg: MeltPoolMessage,
thermal_history_output: str | None = None,
):

@@ -321,5 +225,3 @@ """Initialize a ``SingleBeadSummary`` object."""

self._input = input
self._melt_pool = MeltPool(msg, thermal_history_output)
if thermal_history_output is not None:
self._extract_thermal_history(thermal_history_output)
self._melt_pool = MeltPool(msg)

@@ -341,11 +243,1 @@ @property

return repr
def _extract_thermal_history(self, thermal_history_output):
"""Extract the thermal history output."""
zip_file = os.path.join(thermal_history_output, self.THERMAL_HISTORY_OUTPUT_ZIP)
if not os.path.isfile(zip_file):
raise FileNotFoundError("Thermal history files not found: " + zip_file)
with zipfile.ZipFile(zip_file, "r") as zip_ref:
zip_ref.extractall(thermal_history_output)
with contextlib.suppress(OSError):
os.remove(zip_file)

@@ -23,19 +23,20 @@ # Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.

"""Provides input and result summary containers for thermal history simulations."""
from __future__ import annotations
from ansys.additive.core.geometry_file import BuildFile, StlFile
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
from ansys.additive.core.simulation_input_base import SimulationInputBase
from ansys.api.additive.v0.additive_domain_pb2 import BuildFile as BuildFileMessage
from ansys.api.additive.v0.additive_domain_pb2 import (
CoaxialAverageSensorInputs as CoaxialAverageSensorInputsMessage,
)
from ansys.api.additive.v0.additive_domain_pb2 import Range as RangeMessage
from ansys.api.additive.v0.additive_domain_pb2 import StlFile as StlFileMessage
from ansys.api.additive.v0.additive_domain_pb2 import (
ThermalHistoryInput as ThermalHistoryInputMessage,
)
from ansys.api.additive.v0.additive_domain_pb2 import BuildFile as BuildFileMessage
from ansys.api.additive.v0.additive_domain_pb2 import Range as RangeMessage
from ansys.api.additive.v0.additive_domain_pb2 import StlFile as StlFileMessage
from ansys.api.additive.v0.additive_simulation_pb2 import SimulationRequest
from ansys.additive.core.geometry_file import BuildFile, StlFile
from ansys.additive.core.machine import AdditiveMachine
from ansys.additive.core.material import AdditiveMaterial
class Range:

@@ -129,8 +130,5 @@ """Defines a range of values."""

def _to_coaxial_average_sensor_inputs_message(
self,
) -> CoaxialAverageSensorInputsMessage:
def _to_coaxial_average_sensor_inputs_message(self) -> CoaxialAverageSensorInputsMessage:
"""Create a coaxial average sensor input message to send to the server
based upon this object.
"""
based upon this object."""
msg = CoaxialAverageSensorInputsMessage(sensor_radius=self.radius)

@@ -142,3 +140,3 @@ for z in self.z_heights:

class ThermalHistoryInput(SimulationInputBase):
class ThermalHistoryInput:
"""Provides input parameters for microstructure simulation."""

@@ -148,16 +146,14 @@

self,
*,
machine: AdditiveMachine = None,
material: AdditiveMaterial = None,
id: str = "",
machine: AdditiveMachine = AdditiveMachine(),
material: AdditiveMaterial = AdditiveMaterial(),
geometry: StlFile | BuildFile = None,
coax_ave_sensor_inputs: CoaxialAverageSensorInputs = None,
coax_ave_sensor_inputs: CoaxialAverageSensorInputs = CoaxialAverageSensorInputs(),
):
"""Initialize a ``ThermalHistoryInput`` object."""
super().__init__()
self._machine = machine if machine else AdditiveMachine()
self._material = material if material else AdditiveMaterial()
self._id = id
self._machine = machine
self._material = material
self._geometry = geometry
self._coax_ave_sensor_inputs = (
coax_ave_sensor_inputs if coax_ave_sensor_inputs else CoaxialAverageSensorInputs()
)
self._coax_ave_sensor_inputs = coax_ave_sensor_inputs

@@ -176,5 +172,17 @@ def __repr__(self):

return False
return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
for k in self.__dict__:
if getattr(self, k) != getattr(other, k):
return False
return True
@property
def id(self) -> str:
"""User-provided ID for the simulation."""
return self._id
@id.setter
def id(self, value):
self._id = value
@property
def machine(self) -> AdditiveMachine:

@@ -181,0 +189,0 @@ """Machine parameters."""

# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Provides a class to update progress when running parametric study simulations."""
import threading
from ansys.additive.core.logger import LOG
from ansys.additive.core.parametric_study import ParametricStudy
from ansys.additive.core.progress_handler import (
IProgressHandler,
Progress,
ProgressState,
)
from ansys.additive.core.simulation import SimulationStatus
class ParametricStudyProgressHandler(IProgressHandler):
"""Provides methods to update parametric study simulation status.
Parameters
----------
study : ParametricStudy
Parametric study to update.
"""
def __init__(
self,
study: ParametricStudy,
) -> None:
"""Initialize progress handler."""
self._study_lock = threading.Lock()
self._study = study
# Store the last state of each simulation to avoid
# unnecessary disk writes when setting the simulation status
# on the study.
self._last_progress_states = {}
def update(self, progress: Progress) -> None:
"""Update the progress of a simulation.
Parameters
----------
progress : Progress
Progress information for the simulation.
"""
if (
progress.sim_id in self._last_progress_states
and progress.state == self._last_progress_states[progress.sim_id]
):
return
LOG.debug(f"Updating progress for {progress.sim_id}")
if progress.state == ProgressState.WAITING:
self._update_simulation_status(progress.sim_id, SimulationStatus.PENDING)
elif progress.state == ProgressState.CANCELLED:
self._update_simulation_status(progress.sim_id, SimulationStatus.CANCELLED)
elif progress.state == ProgressState.RUNNING:
self._update_simulation_status(progress.sim_id, SimulationStatus.RUNNING)
elif progress.state == ProgressState.WARNING:
self._update_simulation_status(progress.sim_id, SimulationStatus.WARNING)
elif progress.state == ProgressState.COMPLETED:
self._update_simulation_status(progress.sim_id, SimulationStatus.COMPLETED)
elif progress.state == ProgressState.ERROR:
self._update_simulation_status(
progress.sim_id, SimulationStatus.ERROR, progress.message
)
self._last_progress_states[progress.sim_id] = progress.state
def _update_simulation_status(
self, sim_id: str, status: SimulationStatus, message: str = None
) -> None:
"""Update the status of a simulation.
Parameters
----------
sim_id : str
Simulation ID.
status : SimulationStatus
Simulation status.
message : str, optional
Status message.
"""
with self._study_lock:
self._study.set_simulation_status(sim_id, status, message)
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Provides a base class for simulation inputs."""
from ansys.additive.core import misc
class SimulationInputBase:
"""Provides a base class for simulation inputs."""
def __init__(self) -> None:
"""Initialize the simulation input base class."""
self._id: str = misc.short_uuid()
@property
def id(self) -> str:
"""Return a unique identifier for this simulation."""
return self._id
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Set up methods for grpc simulation requests."""
import hashlib
import os
from collections.abc import Iterator
from ansys.additive.core.microstructure import MicrostructureInput
from ansys.additive.core.microstructure_3d import Microstructure3DInput
from ansys.additive.core.porosity import PorosityInput
from ansys.additive.core.progress_handler import (
IProgressHandler,
Progress,
ProgressState,
)
from ansys.additive.core.server_connection import ServerConnection
from ansys.additive.core.single_bead import SingleBeadInput
from ansys.additive.core.thermal_history import ThermalHistoryInput
from ansys.api.additive.v0.additive_simulation_pb2 import (
SimulationRequest,
UploadFileRequest,
)
def __file_upload_reader(file_name: str, chunk_size=2 * 1024**2) -> Iterator[UploadFileRequest]:
"""Read a file and return an iterator of UploadFileRequests."""
file_size = os.path.getsize(file_name)
short_name = os.path.basename(file_name)
with open(file_name, mode="rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield UploadFileRequest(
name=short_name,
total_size=file_size,
content=chunk,
content_md5=hashlib.md5(chunk).hexdigest(), # noqa: S324
)
def _setup_thermal_history(
input: ThermalHistoryInput,
server: ServerConnection,
progress_handler: IProgressHandler | None = None,
) -> SimulationRequest:
"""Initialize a thermal history simulation.
Parameters
----------
input: ThermalHistoryInput
Simulation input parameters.
server: ServerConnection
Server to use for the simulation.
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
Returns
-------
:class:`SimulationRequest`
"""
if not input.geometry or not input.geometry.path:
raise ValueError("The geometry path is not defined in the simulation input")
remote_geometry_path = ""
for response in server.simulation_stub.UploadFile(__file_upload_reader(input.geometry.path)):
remote_geometry_path = response.remote_file_name
progress = Progress.from_proto_msg(input.id, response.progress)
if progress_handler:
progress_handler.update(progress)
if progress.state == ProgressState.ERROR:
raise Exception(progress.message)
return input._to_simulation_request(remote_geometry_path=remote_geometry_path)
def create_request(
simulation_input: (
SingleBeadInput
| PorosityInput
| MicrostructureInput
| ThermalHistoryInput
| Microstructure3DInput
),
server: ServerConnection,
progress_handler: IProgressHandler | None = None,
) -> SimulationRequest:
"""Create a simulation request and set up any pre-requisites on a server, such as an STL file for a
thermal history simulation.
Parameters
----------
simulation_input: SingleBeadInput, PorosityInput, MicrostructureInput, ThermalHistoryInput, Microstructure3DInput
Parameters to use for simulation.
server: ServerConnection
Server to use for the simulation.
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
Returns
-------
A SimulationRequest
""" # noqa: E501
if isinstance(simulation_input, ThermalHistoryInput):
request = _setup_thermal_history(simulation_input, server, progress_handler)
else:
request = simulation_input._to_simulation_request()
return request
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Manages simulation tasks."""
import time
from ansys.additive.core.logger import LOG
from ansys.additive.core.progress_handler import IProgressHandler, Progress
from ansys.additive.core.simulation_task import SimulationTask
class SimulationTaskManager:
"""Provides a manager for simulation tasks."""
def __init__(self):
"""Initialize the simulation task manager."""
self._tasks: list[SimulationTask] = []
@property
def tasks(self) -> list[SimulationTask]:
"""Get the list of tasks managed by this manager."""
return self._tasks
@property
def simulation_ids(self) -> list[str]:
"""Get the list of the simulation ids managed by this manager."""
return [x.simulation_id for x in self._tasks]
@property
def done(self) -> bool:
"""Check if all tasks are done."""
return all(t.done for t in self._tasks)
def add_task(self, task: SimulationTask):
"""Add a task to this manager.
Parameters
----------
task: SimulationTask
The simulation task holding the long-running operation and corresponding server.
"""
self._tasks.append(task)
def status(
self, progress_handler: IProgressHandler | None = None
) -> list[tuple[str, Progress]]:
"""Get status of each operation stored in this manager.
Parameters
----------
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
Returns
-------
List of tuples with each tuple containing the operation name and an instance of Progress
"""
status_all = []
for t in self._tasks:
progress = t.status()
status_all.append((progress.sim_id, progress))
if progress_handler:
progress_handler.update(progress)
return status_all
def wait_all(self, progress_handler: IProgressHandler | None = None) -> None:
"""Wait for all simulations to finish. A simple loop that waits for each task will wait for the
simulation that takes the longest. This works because wait returns immediately if operation is done.
Parameters
----------
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
"""
LOG.debug(f"Waiting for {len(self._tasks)} tasks to complete")
for t in self._tasks:
t.wait(progress_handler=progress_handler)
def cancel_all(self) -> None:
"""Cancel all simulations belonging to this simulation task manager."""
LOG.debug("Cancelling all tasks")
for t in self._tasks:
t.cancel()
time.sleep(0.1)
def summaries(self):
"""Get a list of the summaries of completed simulations only."""
return [t.summary for t in self._tasks if t.summary]
# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Container for a simulation task."""
import os
import zipfile
from google.longrunning.operations_pb2 import (
CancelOperationRequest,
GetOperationRequest,
Operation,
WaitOperationRequest,
)
from google.protobuf.any_pb2 import Any
from google.protobuf.duration_pb2 import Duration
from google.rpc.code_pb2 import Code
from ansys.additive.core.download import download_file
from ansys.additive.core.logger import LOG
from ansys.additive.core.material_tuning import (
MaterialTuningInput,
MaterialTuningSummary,
)
from ansys.additive.core.microstructure import (
MicrostructureInput,
MicrostructureSummary,
)
from ansys.additive.core.microstructure_3d import (
Microstructure3DInput,
Microstructure3DSummary,
)
from ansys.additive.core.porosity import PorosityInput, PorositySummary
from ansys.additive.core.progress_handler import (
IProgressHandler,
Progress,
ProgressState,
)
from ansys.additive.core.server_connection import ServerConnection
from ansys.additive.core.simulation import SimulationError
from ansys.additive.core.single_bead import SingleBeadInput, SingleBeadSummary
from ansys.additive.core.thermal_history import (
ThermalHistoryInput,
ThermalHistorySummary,
)
from ansys.api.additive.v0.additive_materials_pb2 import TuneMaterialResponse
from ansys.api.additive.v0.additive_operations_pb2 import OperationMetadata
from ansys.api.additive.v0.additive_simulation_pb2 import SimulationResponse
class SimulationTask:
"""Provides a simulation task.
Parameters
----------
server_connection: ServerConnection
The client connection to the Additive server.
long_running_operation: Operation
The long-running operation representing the simulation on the server.
simulation_input: SingleBeadInput | PorosityInput | MicrostructureInput | ThermalHistoryInput | Microstructure3DInput | MaterialTuningInput
The simulation input.
user_data_path: str
The path to the user data directory.
""" # noqa: E501
def __init__(
self,
server_connection: ServerConnection,
long_running_operation: Operation,
simulation_input: (
SingleBeadInput
| PorosityInput
| MicrostructureInput
| ThermalHistoryInput
| Microstructure3DInput
| MaterialTuningInput
),
user_data_path: str,
):
"""Initialize the simulation task."""
self._server = server_connection
self._user_data_path = user_data_path
self._long_running_op = long_running_operation
self._simulation_input = simulation_input
self._summary = None
@property
def simulation_id(self) -> str:
"""Get the simulation id associated with this task."""
return self._simulation_input.id
@property
def summary(
self,
) -> (
SingleBeadSummary
| PorositySummary
| MicrostructureSummary
| Microstructure3DSummary
| ThermalHistorySummary
| MaterialTuningSummary
| SimulationError
| None
):
"""The summary of the completed simulation.
None if simulation is not completed.
"""
return self._summary
def status(self) -> Progress:
"""Fetch status from the server to update progress and results.
Parameters
----------
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
Return
------
Progress
The progress of the operation.
"""
get_request = GetOperationRequest(name=self._long_running_op.name)
self._long_running_op = self._server.operations_stub.GetOperation(get_request)
progress = self._update_operation_status(self._long_running_op)
return progress
def wait(
self,
*,
progress_update_interval: int = 5,
progress_handler: IProgressHandler | None = None,
) -> None:
"""Wait for simulation to finish while updating progress.
Parameters
----------
progress_update_interval: int, default: 5
A timeout value (in seconds) to give to the looped WaitOperation() calls to return an
updated message for a progress update.
progress_handler: IProgressHandler, None, default: None
Handler for progress updates. If ``None``, no progress updates are provided.
"""
LOG.debug(f"Waiting for {self._long_running_op.name} to complete")
try:
while True:
timeout = Duration(seconds=progress_update_interval)
wait_request = WaitOperationRequest(
name=self._long_running_op.name, timeout=timeout
)
awaited_operation = self._server.operations_stub.WaitOperation(wait_request)
progress = self._update_operation_status(awaited_operation)
if progress_handler:
progress_handler.update(progress)
if awaited_operation.done:
break
except Exception as e:
LOG.error(f"Error while awaiting operation: {e}")
# Perform a call to status to ensure all messages are received and summary is updated
progress = self.status()
if progress_handler:
progress_handler.update(progress)
def cancel(self) -> None:
"""Cancel a running simulation."""
LOG.debug(f"Cancelling {self._long_running_op.name}")
request = CancelOperationRequest(name=self._long_running_op.name)
self._server.operations_stub.CancelOperation(request)
@property
def done(self) -> bool:
"""Check if the task is completed."""
return self._long_running_op.done
@staticmethod
def _convert_metadata_to_progress(
metadata_message: Any,
) -> Progress:
"""Update the progress handler with a metadata message.
Parameters
----------
metadata_message: [google.protobuf.Any]
The metadata field of the Operation protobuf message prior to unpacking
to an OperationMetadata class.
Returns
-------
An instance of a Progress class
"""
metadata = OperationMetadata()
metadata_message.Unpack(metadata)
return Progress.from_operation_metadata(metadata)
def _unpack_summary(
self,
operation: Operation,
) -> Progress:
"""Update the simulation summaries.
If an operation is completed, either the "error" field or
the "response" field is available. Update the progress according to which
field is available.
Parameters
----------
operation: [google.longrunning.Operation]
The long-running operation.
Return
------
Progress
The progress of the operation.
"""
progress = self._convert_metadata_to_progress(operation.metadata)
if progress.state == ProgressState.ERROR:
self._summary = SimulationError(self._simulation_input, progress.message)
return progress
if operation.HasField("response"):
response = (
SimulationResponse()
if not isinstance(self._simulation_input, MaterialTuningInput)
else TuneMaterialResponse()
)
operation.response.Unpack(response)
self._summary = self._create_summary_from_response(response)
elif operation.HasField("error") and operation.error.code not in [
Code.CANCELLED,
Code.OK,
]:
self._summary = SimulationError(self._simulation_input, operation.error.message)
return progress
def _update_operation_status(
self,
operation: Operation,
) -> Progress:
"""Update progress or summary.
If operation is done, update summary and progress.
Otherwise, update progress only.
Parameters
----------
operation: [google.longrunning.Operation]
The long-running operation.
Returns
-------
Progress
Progress created from long-running operation metadata.
"""
if operation.done:
# If operation is completed, get the summary and update progress. The server
# should always mark the operation done on either a successful completion or
# a simulation error.
progress = self._unpack_summary(operation)
else:
# Otherwise, just update progress
progress = self._convert_metadata_to_progress(operation.metadata)
return progress
def _create_summary_from_response(
self, response: SimulationResponse | TuneMaterialResponse
) -> (
SingleBeadSummary
| PorositySummary
| MicrostructureSummary
| ThermalHistorySummary
| Microstructure3DSummary
| MaterialTuningSummary
):
if isinstance(response, TuneMaterialResponse):
return MaterialTuningSummary(
self._simulation_input, response.result, self._user_data_path
)
if response.HasField("melt_pool"):
thermal_history_output = None
if self._check_if_thermal_history_is_present(response):
thermal_history_output = os.path.join(
self._user_data_path, self._simulation_input.id, "thermal_history"
)
download_file(
self._server.simulation_stub,
response.melt_pool.thermal_history_vtk_zip,
thermal_history_output,
)
return SingleBeadSummary(
self._simulation_input, response.melt_pool, thermal_history_output
)
if response.HasField("porosity_result"):
return PorositySummary(self._simulation_input, response.porosity_result)
if response.HasField("microstructure_result"):
return MicrostructureSummary(
self._simulation_input,
response.microstructure_result,
self._user_data_path,
)
if response.HasField("microstructure_3d_result"):
return Microstructure3DSummary(
self._simulation_input,
response.microstructure_3d_result,
self._user_data_path,
)
if response.HasField("thermal_history_result"):
path = os.path.join(self._user_data_path, self._simulation_input.id, "coax_ave_output")
local_zip = download_file(
self._server.simulation_stub,
response.thermal_history_result.coax_ave_zip_file,
path,
)
with zipfile.ZipFile(local_zip, "r") as zip:
zip.extractall(path)
os.remove(local_zip)
return ThermalHistorySummary(self._simulation_input, path)
def _check_if_thermal_history_is_present(self, response) -> bool:
"""Check if thermal history output is present in the response."""
return response.melt_pool.thermal_history_vtk_zip != str()

Sorry, the diff of this file is too big to display