diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 3b470bbc2..37a41b65e 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -1,6 +1,7 @@ name: GitHub CI on: pull_request: + pull_request_target: merge_group: workflow_dispatch: push: @@ -14,7 +15,7 @@ env: DOCUMENTATION_CNAME: "additive.docs.pyansys.com" LIBRARY_NAME: "ansys-additive-core" # NOTE: The server needs to stay in a private registry. - ANSYS_PRODUCT_IMAGE: "ghcr.io/ansys-internal/additive:24.1.0-alpha10" + ANSYS_PRODUCT_IMAGE: "ghcr.io/ansys-internal/additive:24.2.0-alpha1" ANSYS_PRODUCT_CONTAINER: "ansys-additive-container" concurrency: @@ -89,7 +90,6 @@ jobs: with: files: .cov/xml - doc-build: name: "Building library documentation" if: github.event_name != 'merge_group' diff --git a/doc/source/contributing.rst b/doc/source/contributing.rst index 8bc585d29..9d9c9ce01 100644 --- a/doc/source/contributing.rst +++ b/doc/source/contributing.rst @@ -9,21 +9,12 @@ Overall guidance on contributing to a PyAnsys library appears in the in the *PyAnsys developer's guide*. Ensure that you are thoroughly familiar with this guide before attempting to contribute to PyAdditive. -The following contribution information is specific to PyAdditive. - -Clone the repository --------------------- - -To clone and install the latest PyAdditive release in development mode, run -these commands: - -.. code:: - - git clone https://github.com/ansys/pyadditive - cd pyadditive - python -m pip install --upgrade pip - pip install -e . +Configure your development environment +-------------------------------------- +For instructions on setting up your development environment, see +:ref:`ref_getting_started`, particularly the :ref:`ref_install_in_developer_mode` +section. Post issues ----------- diff --git a/doc/source/getting_started/index.rst b/doc/source/getting_started/index.rst index 86986bf97..e31cb6ca1 100644 --- a/doc/source/getting_started/index.rst +++ b/doc/source/getting_started/index.rst @@ -100,6 +100,8 @@ Then, run this command to install PyAdditive: python -m pip install ansys-additive-core +.. _ref_install_in_developer_mode: + Install in developer mode ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pyproject.toml b/pyproject.toml index a4259c647..64a64c6c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "flit_core.buildapi" [project] # Check https://flit.readthedocs.io/en/latest/pyproject_toml.html for all available sections name = "ansys-additive-core" -version = "0.17.dev3" +version = "0.17.dev4" description = "A Python client for the Ansys Additive service" readme = "README.rst" requires-python = ">=3.9,<4" @@ -38,7 +38,7 @@ dependencies = [ "panel>=1.2.1", "platformdirs>=3.8.0", "plotly>=5.16.1", - "protobuf~=3.20.2", + "protobuf>=3.20.2,<5", "six>=1.16.0", "tqdm>=4.45.0", ] @@ -119,7 +119,7 @@ show_missing = true [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 96" +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*"] diff --git a/src/ansys/additive/core/additive.py b/src/ansys/additive/core/additive.py index 421ce61a8..2626fbf45 100644 --- a/src/ansys/additive/core/additive.py +++ b/src/ansys/additive/core/additive.py @@ -55,6 +55,15 @@ class Additive: """Provides the client interface to one or more Additive services. + 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 ---------- server_connections: list[str, grpc.Channel], None @@ -66,21 +75,49 @@ class Additive: ``server_channels`` or ``channel`` parameters is other than ``None``. port: int, default: 50052 Port number to use when connecting to the server. + nsims_per_server: int, default: 1 + 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. - ``nservers`` instances of Additive server will be started. For this to work, - the Additive portion of the Ansys Structures package must be installed. - This parameter is ignored if the ``server_connections``, ``channel``, or ``host`` - parameter is other than ``None``. + 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 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 environments and on localhost. + `PyPIM`_-enabled cloud environments and on localhost. Using an empty string + or ``None`` uses the default product version. log_level: str, default: "INFO" Minimum severity level of messages to log. log_file: str, default: "" File name to write log messages to. + + Examples + -------- + Connect to a list of servers. Multiple connections to the same host are permitted. + + >>> additive = Additive(server_connections=["localhost:50052", "localhost:50052", "myserver:50052"]) + + Connect to a single server using the host name and port number. + + >>> additive = Additive(host="additive.ansys.com", port=12345) + + 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, nservers=2) + + Start a single server on localhost or in a `PyPIM`_-enabled cloud environment. + Use version 2024 R1 of the Ansys product installation. + + >>> additive = Additive(product_version="241") + + .. _PyPIM: https://pypim.docs.pyansys.com/version/stable/index.html """ DEFAULT_ADDITIVE_SERVICE_PORT = 50052 @@ -90,18 +127,23 @@ def __init__( 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 = "INFO", log_file: str = "", ) -> None: """Initialize server connections.""" + if product_version is None or product_version == "": + product_version = DEFAULT_PRODUCT_VERSION + self._log = Additive._create_logger(log_file, log_level) self._log.debug("Logging set to %s", log_level) self._servers = Additive._connect_to_servers( server_connections, host, port, nservers, product_version, self._log ) + self._nsims_per_server = nsims_per_server # Setup data directory self._user_data_path = USER_DATA_PATH @@ -158,14 +200,25 @@ def _connect_to_servers( 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 + 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("Not connected to server") + print("Client is not connected to a server.") return else: - print("_servers not None") for server in self._servers: print(server.status()) @@ -206,7 +259,8 @@ def simulate( f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} Completed 0 of {len(inputs)} simulations", end="", ) - with concurrent.futures.ThreadPoolExecutor(len(self._servers)) as executor: + 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) diff --git a/src/ansys/additive/core/server_connection/server_connection.py b/src/ansys/additive/core/server_connection/server_connection.py index c3c896e6e..ce593f887 100644 --- a/src/ansys/additive/core/server_connection/server_connection.py +++ b/src/ansys/additive/core/server_connection/server_connection.py @@ -64,10 +64,11 @@ class ServerConnectionStatus: class ServerConnection: """Provides connection to Additive server. - If neither ``channel`` nor ``addr`` are provided, an attempt will be - made to start an Additive server and connect to it. If running in a - cloud environment, :class:`PyPIM ` - must be supported. If running on localhost, the Additive option of the + If neither ``channel`` nor ``addr`` are provided, an attempt is + made to start an Additive server and connect to it. Starting a server + in a cloud environment requires + `PyPIM `_ to be available. + To start a server when running on localhost, the Additive option of the Structures package of the Ansys unified installation must be installed. Parameters @@ -80,7 +81,8 @@ class ServerConnection: 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 environments and on localhost. + `PyPIM `_-enabled + cloud environments and on localhost. log: logging.Logger, None Log to write connection messages to. """ diff --git a/tests/server_connection/test_local_server.py b/tests/server_connection/test_local_server.py index ca1b9912d..f288c29de 100644 --- a/tests/server_connection/test_local_server.py +++ b/tests/server_connection/test_local_server.py @@ -87,11 +87,11 @@ def test_launch_with_linux_installation_but_invalid_ansys_version_raises_excepti @pytest.mark.skipif(os.name != "nt", reason="Test only valid on Windows") def test_launch_when_exe_not_found_raises_exception_win(): # arrange - os.environ["AWP_ROOT241"] = "Bogus" + os.environ["AWP_ROOT123"] = "Bogus" # act, assert with pytest.raises(FileNotFoundError) as excinfo: - LocalServer.launch(TEST_VALID_PORT) + LocalServer.launch(TEST_VALID_PORT, product_version="123") assert "Cannot find " in str(excinfo.value) diff --git a/tests/test_additive.py b/tests/test_additive.py index a790771b9..5d8f22a24 100644 --- a/tests/test_additive.py +++ b/tests/test_additive.py @@ -68,19 +68,28 @@ import ansys.additive.core.additive from ansys.additive.core.material import AdditiveMaterial from ansys.additive.core.material_tuning import MaterialTuningInput -from ansys.additive.core.server_connection import ServerConnection +from ansys.additive.core.server_connection import DEFAULT_PRODUCT_VERSION, ServerConnection import ansys.additive.core.server_connection.server_connection from . import test_utils -def test_Additive_init_calls_connect_to_servers_correctly(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize( + "in_prod_version, expected_prod_version", + [ + (None, DEFAULT_PRODUCT_VERSION), + ("123", "123"), + ("", DEFAULT_PRODUCT_VERSION), + ], +) +def test_Additive_init_calls_connect_to_servers_correctly( + monkeypatch: pytest.MonkeyPatch, in_prod_version, expected_prod_version +): # arrange server_connections = ["connection1", "connection2"] host = "hostname" port = 12345 nservers = 3 - product_version = "123" mock_server_connections = [Mock(ServerConnection)] mock_connect = create_autospec( @@ -90,15 +99,57 @@ def test_Additive_init_calls_connect_to_servers_correctly(monkeypatch: pytest.Mo monkeypatch.setattr(ansys.additive.core.additive.Additive, "_connect_to_servers", mock_connect) # act - additive = Additive(server_connections, host, port, nservers, product_version) + additive = Additive( + server_connections, host, port, nservers=nservers, product_version=in_prod_version + ) # assert - mock_connect.assert_called_with(server_connections, host, port, nservers, product_version, ANY) + mock_connect.assert_called_with( + server_connections, host, port, nservers, expected_prod_version, ANY + ) assert additive._servers == mock_server_connections assert isinstance(additive._log, logging.Logger) assert additive._user_data_path == USER_DATA_PATH +@patch("ansys.additive.core.additive.ServerConnection") +def test_Additive_init_assigns_nsims_per_servers(_): + # arrange + nsims_per_server = 99 + + # act + additive_default = Additive() + additive = Additive(nsims_per_server=nsims_per_server) + + # assert + assert additive_default.nsims_per_server == 1 + assert additive.nsims_per_server == nsims_per_server + + +@patch("ansys.additive.core.additive.ServerConnection") +def test_nsims_per_servers_setter_raises_exception_for_invalid_value(_): + # arrange + nsims_per_server = -1 + additive = Additive() + + # act, assert + with pytest.raises(ValueError, match="must be greater than zero"): + additive.nsims_per_server = nsims_per_server + + +@patch("ansys.additive.core.additive.ServerConnection") +def test_nsims_per_servers_setter_correctly_assigns_valid_value(_): + # arrange + nsims_per_server = 99 + additive = Additive() + + # act + additive.nsims_per_server = nsims_per_server + + # assert + assert additive._nsims_per_server == nsims_per_server + + @patch("ansys.additive.core.additive.ServerConnection") def test_connect_to_servers_with_server_connections_creates_server_connections(mock_connection): # arrange @@ -223,7 +274,7 @@ def test_about_prints_not_connected_message(capsys: pytest.CaptureFixture[str]): # assert out_str = capsys.readouterr().out assert f"Client {__version__}, API version: {api_version}" in out_str - assert "Not connected to server" in out_str + assert "Client is not connected to a server." in out_str def test_about_prints_server_status_messages(capsys: pytest.CaptureFixture[str]): @@ -326,6 +377,60 @@ def test_simulate_with_input_list_calls_internal_simulate_n_times(_): _simulate_patch.assert_has_calls(calls, any_order=True) +@pytest.mark.parametrize( + "inputs, nservers, nsims_per_server, expected_n_threads", + [ + ( + [ + SingleBeadInput(id="id1"), + PorosityInput(id="id2"), + MicrostructureInput(id="id3"), + ThermalHistoryInput(id="id4"), + SingleBeadInput(id="id5"), + ], + 2, + 2, + 4, + ), + ( + [ + SingleBeadInput(id="id1"), + PorosityInput(id="id2"), + MicrostructureInput(id="id3"), + ThermalHistoryInput(id="id4"), + ], + 3, + 2, + 4, + ), + ], +) +@patch("ansys.additive.core.additive.ServerConnection") +@patch("concurrent.futures.ThreadPoolExecutor") +def test_simulate_with_n_servers_m_sims_per_server_uses_n_x_m_threads( + mock_executor, mock_connection, inputs, nservers, nsims_per_server, expected_n_threads +): + # arrange + mock_connection.return_value = Mock(ServerConnection) + + def raise_exception(_): + raise Exception("exception") + + # mock_executor.return_value = concurrent.futures.ThreadPoolExecutor() + mock_executor.side_effect = raise_exception + additive = Additive(nservers=nservers, nsims_per_server=nsims_per_server) + + # act + try: + additive.simulate(inputs) + except Exception: + pass + + # assert + assert mock_connection.call_count == nservers + mock_executor.assert_called_once_with(expected_n_threads) + + # patch needed for Additive() call @patch("ansys.additive.core.additive.ServerConnection") def test_simulate_with_duplicate_simulation_ids_raises_exception(_):