diff --git a/doc/changelog.d/6996.added.md b/doc/changelog.d/6996.added.md new file mode 100644 index 00000000000..2a733fbfd62 --- /dev/null +++ b/doc/changelog.d/6996.added.md @@ -0,0 +1 @@ +Add fields calc expressions and update fields distribution extension diff --git a/src/ansys/aedt/core/extensions/maxwell3d/fields_distribution.py b/src/ansys/aedt/core/extensions/maxwell3d/fields_distribution.py index bef1c73e99c..360888d94be 100644 --- a/src/ansys/aedt/core/extensions/maxwell3d/fields_distribution.py +++ b/src/ansys/aedt/core/extensions/maxwell3d/fields_distribution.py @@ -128,17 +128,20 @@ def check_design_type(self): def __load_aedt_info(self): """Load Maxwell design info.""" # Get named expressions for field quantities - point = self.aedt_application.modeler.create_point([0, 0, 0]) - self.__named_expressions = self.aedt_application.post.available_report_quantities( - report_category="Fields", context=point.name, quantities_category="Calculator Expressions" - ) - - # Load vector fields from JSON - json_path = Path(__file__).resolve().parent / "vector_fields.json" - with open(json_path, "r") as f: - vector_fields = json.load(f) - self.__named_expressions.extend(vector_fields[self.aedt_application.design_type]) - point.delete() + # make it backward compatible by implementing a check on AEDT version + if self.aedt_application._aedt_version < "2026.1": + point = self.aedt_application.modeler.create_point([0, 0, 0]) + self.__named_expressions = self.aedt_application.post.available_report_quantities( + report_category="Fields", context=point.name, quantities_category="Calculator Expressions" + ) + # Load vector fields from JSON + json_path = Path(__file__).resolve().parent / "vector_fields.json" + with open(json_path, "r") as f: + vector_fields = json.load(f) + self.__named_expressions.extend(vector_fields[self.aedt_application.design_type]) + point.delete() + else: + self.__named_expressions = self.aedt_application.post.fields_calculator.get_expressions() # Get objects list self.__objects_list = list(self.aedt_application.modeler.objects_by_name.keys()) diff --git a/src/ansys/aedt/core/visualization/post/fields_calculator.py b/src/ansys/aedt/core/visualization/post/fields_calculator.py index a59de950a56..65cdebb6b00 100644 --- a/src/ansys/aedt/core/visualization/post/fields_calculator.py +++ b/src/ansys/aedt/core/visualization/post/fields_calculator.py @@ -23,18 +23,21 @@ # SOFTWARE. import copy -import os +from pathlib import Path from jsonschema import exceptions from jsonschema import validate -import ansys.aedt.core from ansys.aedt.core.base import PyAedtBase from ansys.aedt.core.generic.file_utils import generate_unique_name from ansys.aedt.core.generic.file_utils import generate_unique_project_name from ansys.aedt.core.generic.file_utils import open_file from ansys.aedt.core.generic.file_utils import read_configuration_file from ansys.aedt.core.generic.general_methods import pyaedt_function_handler +from ansys.aedt.core.internal.checks import min_aedt_version +from ansys.aedt.core.internal.errors import AEDTRuntimeError + +PARENT_DIR = Path(__file__).parent class FieldsCalculator(PyAedtBase): @@ -91,22 +94,10 @@ class FieldsCalculator(PyAedtBase): def __init__(self, app): self.expression_catalog = read_configuration_file( - os.path.join( - ansys.aedt.core.__path__[0], - "visualization", - "post", - "fields_calculator_files", - "expression_catalog.toml", - ) + PARENT_DIR / "fields_calculator_files" / "expression_catalog.toml" ) self.expression_schema = read_configuration_file( - os.path.join( - ansys.aedt.core.__path__[0], - "visualization", - "post", - "fields_calculator_files", - "fields_calculator.schema.json", - ) + PARENT_DIR / "fields_calculator_files" / "fields_calculator.schema.json" ) self.__app = app self.design_type = app.design_type @@ -253,7 +244,7 @@ def add_expression(self, calculation, assignment, name=None): # Import clc self.ofieldsreporter.LoadNamedExpressions( - os.path.abspath(file_name), expression_info["fields_type"][design_type_index], [name] + str(Path(file_name).resolve()), expression_info["fields_type"][design_type_index], [name] ) return expression_info["name"] @@ -289,7 +280,7 @@ def create_expression_file(self, name, operations): file.write("$end 'Named_Expression'\n") except Exception: # pragma: no cover return False - return os.path.abspath(file_name) + return str(Path(file_name).resolve()) @pyaedt_function_handler() def expression_plot(self, calculation, assignment, names, setup=None): @@ -476,21 +467,19 @@ def load_expression_file(self, input_file): -------- >>> from ansys.aedt.core import Hfss >>> hfss = Hfss() - >>> my_toml = os.path.join("my_path_to_toml", "my_toml.toml") + >>> my_toml = str(Path("my_path_to_toml") / "my_toml.toml") >>> new_catalog = hfss.post.fields_calculator.load_expression_file(my_toml) >>> hfss.desktop_class.release_desktop(False, False) """ - if not os.path.isfile(input_file): + if not Path(input_file).is_file(): self.__app.logger.error("File does not exist.") return False new_expression_catalog = read_configuration_file(input_file) - if new_expression_catalog: - for _, new_expression_props in new_expression_catalog.items(): - new_expression = self.validate_expression(new_expression_props) - if new_expression: - self.expression_catalog.update(new_expression) + for new_expression_key, new_expression_props in new_expression_catalog.items(): + if self.validate_expression(new_expression_props): + self.expression_catalog[new_expression_key] = new_expression_props return self.expression_catalog @@ -561,14 +550,14 @@ def write(self, expression, output_file, setup=None, intrinsics=None): >>> hfss = Hfss() >>> poly = hfss.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") >>> expr_name = hfss.post.fields_calculator.add_expression("voltage_line", "Polyline1") - >>> file_path = os.path.join(hfss.working_directory, "my_expr.fld") + >>> file_path = Path(hfss.working_directory) / "my_expr.fld" >>> hfss.post.fields_calculator.write("voltage_line", file_path, hfss.nominal_adaptive) >>> hfss.desktop_class.release_desktop(False, False) """ if not self.is_expression_defined(expression): self.__app.logger.error("Expression does not exist in current stack.") return False - if os.path.splitext(output_file)[1] not in [".fld", ".reg"]: + if Path(output_file).suffix not in [".fld", ".reg"]: self.__app.logger.error("Invalid file extension. Accepted extensions are '.fld' and '.reg'.") return False if not setup: @@ -580,7 +569,11 @@ def write(self, expression, output_file, setup=None, intrinsics=None): self.ofieldsreporter.CalcStack("clear") self.ofieldsreporter.CopyNamedExprToStack(expression) args = [] - for k, v in self.__app.variable_manager.design_variables.items(): + for k, v in ( + self.__app.variable_manager.design_variables | self.__app.variable_manager.project_variables + ).items(): + if "[" in v.evaluated_value: + raise AEDTRuntimeError("The method does not currently support array variables.") args.append(f"{k}:=") args.append(v.expression) intrinsics = self.__app.post._check_intrinsics(intrinsics) @@ -589,7 +582,12 @@ def write(self, expression, output_file, setup=None, intrinsics=None): continue args.append(f"{k}:=") args.append(v) - self.ofieldsreporter.CalculatorWrite(output_file, ["Solution:=", setup], args) + if self.__app.aedt_version_id < "2026.1": + self.ofieldsreporter.CalculatorWrite(output_file, ["Solution:=", setup], args) + else: + solution_args = ["NAME:Setup", "Solution:=", setup] + expression_args = ["NAME:Expression", "NameOfExpression:=", [expression]] + self.ofieldsreporter.CalculatorWrite(output_file, ["NAME:Write", solution_args, expression_args], args) self.ofieldsreporter.CalcStack("clear") return True @@ -620,16 +618,19 @@ def evaluate(self, expression, setup=None, intrinsics=None): float Value computed. """ - out_file = os.path.join(self.__app.working_directory, generate_unique_name("expression") + ".fld") - self.write(expression, setup=setup, intrinsics=intrinsics, output_file=out_file) + out_file = Path(self.__app.working_directory) / (generate_unique_name("expression") + ".fld") + self.write(expression, setup=setup, intrinsics=intrinsics, output_file=str(out_file)) + + # ClcEval does not return any value + # This is why we open the and read the file value = None - if os.path.exists(out_file): + if out_file.exists(): with open_file(out_file, "r") as f: lines = f.readlines() lines = [line.strip() for line in lines] value = lines[-1] try: - os.remove(out_file) + out_file.unlink() except OSError: pass return value @@ -804,10 +805,50 @@ def export( ) return False - if os.path.exists(output_file): - return output_file + if output_file: + output_file = Path(output_file) + + if output_file.exists(): + return str(output_file) return False + @pyaedt_function_handler() + @min_aedt_version("2026.1") + def get_expressions(self, field_type: str = None) -> dict: # pragma: no cover + """Get dictionary of available Field Calculator expressions. + + Parameters + ---------- + field_type : str, optional + Field type. Options are ``"Fields"`` or ``"Time Averaged Fields"``. + The default is ``None``, in which case all expressions are returned. + + Returns + ------- + dict + Field Calculator expressions. + Where key is the named expression and value is the expression string. + + References + ---------- + >>> oModule.GetFieldsCalculatorExpressions + Example + ------- + >>> from ansys.aedt.core import Hfss + >>> hfss = Hfss() + >>> poly = hfss.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + >>> exprs = hfss.post.fields_calculator.get_expressions() + >>> hfss.desktop_class.release_desktop(False, False) + """ + expressions = {} + field_type = field_type or "" + if field_type and field_type not in ["Fields", "Time Averaged Fields"]: + raise AEDTRuntimeError("Invalid field type.") + for expr in self.ofieldsreporter.GetFieldsCalculatorExpressions(field_type): + k, val = map(str.strip, expr.split("=", 1)) + expressions[k] = val + return expressions + @staticmethod def __has_integer(lst): # pragma: no cover """Check if a list has integers.""" diff --git a/tests/system/visualization/test_fields_calculator.py b/tests/system/visualization/test_fields_calculator.py new file mode 100644 index 00000000000..ce752e85400 --- /dev/null +++ b/tests/system/visualization/test_fields_calculator.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2026 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. + + +import pytest + +from ansys.aedt.core import Hfss +from tests.conftest import DESKTOP_VERSION + +test_subfolder = "fields_calculator" + + +@pytest.fixture() +def aedtapp(add_app): + app = add_app(application=Hfss) + yield app + app.close_project(app.project_name) + + +def test_add_expressions(aedtapp): + """Test adding custom field calculator expressions.""" + poly = aedtapp.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + my_expression = { + "name": "test", + "description": "Voltage drop along a line", + "design_type": ["HFSS", "Q3D Extractor"], + "fields_type": ["Fields", "CG Fields"], + "solution_type": "", + "primary_sweep": "Freq", + "assignment": "", + "assignment_type": ["Line"], + "operations": [ + "Fundamental_Quantity('E')", + "Operation('Real')", + "Operation('Tangent')", + "Operation('Dot')", + "EnterLine('assignment')", + "Operation('LineValue')", + "Operation('Integrate')", + "Operation('CmplxR')", + ], + "report": ["Data Table", "Rectangular Plot"], + } + expr_name = aedtapp.post.fields_calculator.add_expression(my_expression, poly.name) + assert isinstance(expr_name, str) + assert expr_name == "test" + + +def test_expression_plot(aedtapp): + """Test creating plots from field calculator expressions.""" + aedtapp.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + aedtapp.create_setup() + expr_name = aedtapp.post.fields_calculator.add_expression("voltage_line", "Polyline1") + reports = aedtapp.post.fields_calculator.expression_plot("voltage_line", "Polyline1", [expr_name]) + assert reports + assert aedtapp.post.plots + assert reports == aedtapp.post.plots + assert reports[0].expressions == ["Voltage_Line"] + + +def test_delete_expression(aedtapp): + """Test deleting named expressions from the fields calculator.""" + poly = aedtapp.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + expr_name = aedtapp.post.fields_calculator.add_expression("voltage_line", poly.name) + aedtapp.post.fields_calculator.delete_expression(expr_name) + aedtapp.post.fields_calculator.delete_expression() + + +def test_is_expression_defined(aedtapp): + """Test checking if a named expression exists in the fields calculator.""" + aedtapp.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + aedtapp.create_setup() + assert not aedtapp.post.fields_calculator.is_expression_defined("Voltage_Line") + expr_name = aedtapp.post.fields_calculator.add_expression("voltage_line", "Polyline1") + assert aedtapp.post.fields_calculator.is_expression_defined(expr_name) + + +def test_is_general_expression(aedtapp): + """Test identifying general vs. assignment-specific expressions.""" + aedtapp.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + aedtapp.create_setup() + assert not aedtapp.post.fields_calculator.is_general_expression("invalid") + assert not aedtapp.post.fields_calculator.is_general_expression("voltage_line") + assert aedtapp.post.fields_calculator.is_general_expression("voltage_drop") + + +def test_validate_expression(aedtapp): + """Test expression validation against the schema.""" + assert not aedtapp.post.fields_calculator.validate_expression("invalid") + + +@pytest.mark.skipif(DESKTOP_VERSION < "2026.1", reason="Native API method only available from AEDT 2026.1 onwards.") +def test_get_expressions(aedtapp): + """Test deleting named expressions from the fields calculator.""" + poly = aedtapp.modeler.create_polyline([[0, 0, 0], [1, 0, 1]], name="Polyline1") + expressions = aedtapp.post.fields_calculator.get_expressions() + assert "Voltage_Line" not in list(expressions.keys()) + aedtapp.post.fields_calculator.add_expression("voltage_line", poly.name) + expressions = aedtapp.post.fields_calculator.get_expressions() + assert "Voltage_Line" in list(expressions.keys()) diff --git a/tests/unit/extensions/conftest.py b/tests/unit/extensions/conftest.py index 0d1de917914..9d07dc71584 100644 --- a/tests/unit/extensions/conftest.py +++ b/tests/unit/extensions/conftest.py @@ -71,21 +71,23 @@ def mock_hfss_3d_layout_app(): @pytest.fixture -def mock_maxwell_3d_app(): +def mock_maxwell_3d_app(request): """Fixture to mock Maxwell 3D application.""" with patch.object(ExtensionCommon, "aedt_application", new_callable=PropertyMock) as mock_property: mock_instance = MagicMock() mock_instance.design_type = "Maxwell 3D" + mock_instance._aedt_version = getattr(request, "param", "2026.1") mock_property.return_value = mock_instance yield mock_instance @pytest.fixture -def mock_maxwell_2d_app(): +def mock_maxwell_2d_app(request): """Fixture to mock Maxwell 2D application.""" with patch.object(ExtensionCommon, "aedt_application", new_callable=PropertyMock) as mock_property: mock_instance = MagicMock() mock_instance.design_type = "Maxwell 2D" + mock_instance._aedt_version = getattr(request, "param", "2026.1") mock_property.return_value = mock_instance yield mock_instance diff --git a/tests/unit/extensions/test_fields_distribution.py b/tests/unit/extensions/test_fields_distribution.py index 0931558bd07..8ff0b4eaabb 100644 --- a/tests/unit/extensions/test_fields_distribution.py +++ b/tests/unit/extensions/test_fields_distribution.py @@ -34,8 +34,9 @@ from ansys.aedt.core.extensions.maxwell3d.fields_distribution import FieldsDistributionExtensionData -def test_extension_default(mock_maxwell_3d_app): - """Test instantiation of the Fields Distribution extension.""" +@pytest.mark.parametrize("mock_maxwell_3d_app", ["2025.2"], indirect=True) +def test_extension_default_with_point(mock_maxwell_3d_app): + """Test instantiation of the Fields Distribution extension for AEDT version < 2026.1.""" # Mock the vector fields JSON file mock_vector_fields = { "Maxwell 3D": ["Vector_H", "Vector_B", "Vector_J"], @@ -71,6 +72,39 @@ def test_extension_default(mock_maxwell_3d_app): extension.root.destroy() +@pytest.mark.parametrize("mock_maxwell_3d_app", ["2026.1"], indirect=True) +def test_extension_default_without_point(mock_maxwell_3d_app): + """Test instantiation of the Fields Distribution extension for AEDT version 2026.1.""" + # Mock the vector fields JSON file + mock_vector_fields = { + "Maxwell 3D": ["Vector_H", "Vector_B", "Vector_J"], + "Maxwell 2D": ["A_Vector", "H_Vector", "B_Vector"], + } + + # Mock the post processing methods + mock_maxwell_3d_app.post.available_report_quantities.return_value = ["Ohmic loss", "Current density"] + mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock(), "Object2": MagicMock()} + mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] + + with ( + patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), + patch("json.load", return_value=mock_vector_fields), + ): + extension = FieldsDistributionExtension(withdraw=True) + + assert EXTENSION_TITLE == extension.root.title() + assert "light" == extension.root.theme + + # Check that UI elements are created + assert "export_options_lb" in extension._widgets + assert "objects_list_lb" in extension._widgets + assert "solution_dropdown_var" in extension._widgets + assert "sample_points_entry" in extension._widgets + assert "export_file_entry" in extension._widgets + + extension.root.destroy() + + def test_extension_data_initialization(): """Test the FieldsDistributionExtensionData initialization.""" data = FieldsDistributionExtensionData() @@ -101,10 +135,6 @@ def test_extension_design_type_check(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -124,9 +154,7 @@ def test_extension_ui_widgets(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock(), "Object2": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive", "Setup2 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point + mock_maxwell_3d_app.post.fields_calculator.get_expressions = MagicMock(return_value=["Expr1", "Expr2"]) with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), @@ -160,10 +188,6 @@ def test_text_size_method(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -195,10 +219,6 @@ def test_populate_listbox_method(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -230,10 +250,6 @@ def test_extension_data_extraction(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock(), "Object2": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -259,9 +275,6 @@ def test_extension_data_extraction(mock_maxwell_3d_app): def test_extension_error_handling(mock_maxwell_3d_app): """Test error handling for missing objects and solutions.""" mock_vector_fields = {"Maxwell 3D": ["Vector_H"], "Maxwell 2D": ["A_Vector"]} - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point # Test with no objects mock_maxwell_3d_app.post.available_report_quantities.return_value = ["Ohmic loss"] @@ -287,6 +300,7 @@ def test_extension_error_handling(mock_maxwell_3d_app): FieldsDistributionExtension(withdraw=True) +@pytest.mark.parametrize("mock_maxwell_2d_app", ["2025.2"], indirect=True) def test_extension_with_maxwell_2d(mock_maxwell_2d_app): """Test extension with Maxwell 2D application.""" mock_vector_fields = {"Maxwell 2D": ["A_Vector", "H_Vector"], "Maxwell 3D": ["Vector_H"]} @@ -327,9 +341,9 @@ def test_callback_export(mock_maxwell_3d_app): } mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point + mock_maxwell_3d_app.post.fields_calculator.get_expressions = MagicMock( + return_value=["Ohmic loss", "Current density"] + ) with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), @@ -365,10 +379,6 @@ def test_callback_export_no_selection(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -410,9 +420,9 @@ def test_callback_preview(mock_maxwell_3d_app): mock_plot.fields = ["field1", "field2"] mock_maxwell_3d_app.post.plot_field.return_value = mock_plot - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point + mock_maxwell_3d_app.post.fields_calculator.get_expressions = MagicMock( + return_value=["Ohmic loss", "Current density"] + ) with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), @@ -451,10 +461,6 @@ def test_callback_preview_no_selection(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -485,9 +491,9 @@ def test_callback_preview_exception(mock_maxwell_3d_app): # Make plot_field raise an exception mock_maxwell_3d_app.post.plot_field.side_effect = Exception("Plot error") - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point + mock_maxwell_3d_app.post.fields_calculator.get_expressions = MagicMock( + return_value=["Ohmic loss", "Current density"] + ) with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), @@ -519,10 +525,6 @@ def test_save_as_files_callback(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -563,10 +565,6 @@ def test_show_points_popup_ui_creation(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -598,10 +596,6 @@ def test_submit_import_file_success(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -645,10 +639,6 @@ def test_submit_import_file_no_selection(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), @@ -691,10 +681,6 @@ def test_popup_destroy_called(mock_maxwell_3d_app): mock_maxwell_3d_app.modeler.objects_by_name = {"Object1": MagicMock()} mock_maxwell_3d_app.existing_analysis_sweeps = ["Setup1 : LastAdaptive"] - mock_point = MagicMock() - mock_point.name = "Point1" - mock_maxwell_3d_app.modeler.create_point.return_value = mock_point - with ( patch("builtins.open", mock_open(read_data=json.dumps(mock_vector_fields))), patch("json.load", return_value=mock_vector_fields), diff --git a/tests/unit/test_fields_calculator.py b/tests/unit/test_fields_calculator.py new file mode 100644 index 00000000000..633c947388e --- /dev/null +++ b/tests/unit/test_fields_calculator.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2026 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. + +from unittest.mock import MagicMock +from unittest.mock import mock_open +from unittest.mock import patch + +import pytest + +from ansys.aedt.core.internal.errors import AEDTRuntimeError +from ansys.aedt.core.visualization.post.fields_calculator import FieldsCalculator + + +@pytest.fixture +def mock_app(tmp_path_factory, request): + app = MagicMock() + app.aedt_version_id = getattr(request, "param", "2025.2") + app.nominal_adaptive = "Setup1 : LastAdaptive" + app.setup_names = ["Setup1"] + app.logger = MagicMock() + app.variable_manager.design_variables = {} + app.variable_manager.project_variables = {} + app.post._check_intrinsics([]) + app.working_directory = tmp_path_factory.mktemp("aedt_working_dir") + return app + + +@pytest.fixture +def mock_app_array(tmp_path_factory, request): + app = MagicMock() + app.aedt_version_id = getattr(request, "param", "2025.2") + app.nominal_adaptive = "Setup1 : LastAdaptive" + app.setup_names = ["Setup1"] + app.logger = MagicMock() + v = MagicMock() + v.evaluated_value = "[1,2,3]mm" + app.variable_manager.design_variables = {"test_array": v} + app.variable_manager.project_variables = {} + app.post._check_intrinsics([]) + app.working_directory = tmp_path_factory.mktemp("aedt_working_dir") + return app + + +def test_write_empty_design_variables(mock_app, test_tmp_dir): + """Test the write method of FieldsCalculator class with no array variables.""" + fields_calculator = FieldsCalculator(mock_app) + fields_calculator.ofieldsreporter = MagicMock() + fields_calculator.is_expression_defined = MagicMock(return_value=True) + output_file = test_tmp_dir / "expr.fld" + + result = fields_calculator.write( + expression="my_expr", + output_file=str(output_file), + setup=mock_app.nominal_adaptive, + intrinsics=None, + ) + + assert result is True + + +@pytest.mark.parametrize("mock_app", ["2026.1"], indirect=True) +def test_write_20261(mock_app, test_tmp_dir): + """Test the write method of FieldsCalculator class with no array variables in AEDT 2026.1.""" + fields_calculator = FieldsCalculator(mock_app) + fields_calculator.ofieldsreporter = MagicMock() + fields_calculator.is_expression_defined = MagicMock(return_value=True) + output_file = test_tmp_dir / "expr.fld" + + result = fields_calculator.write( + expression="my_expr", + output_file=str(output_file), + setup=mock_app.nominal_adaptive, + intrinsics=None, + ) + + assert result is True + + +def test_write_array_design_variables(mock_app_array, test_tmp_dir): + """Test the write method of FieldsCalculator class with array variables.""" + fields_calculator = FieldsCalculator(mock_app_array) + fields_calculator.ofieldsreporter = MagicMock() + fields_calculator.is_expression_defined = MagicMock(return_value=True) + output_file = test_tmp_dir / "expr.fld" + + with pytest.raises(AEDTRuntimeError): + fields_calculator.write( + expression="my_expr", + output_file=str(output_file), + setup=mock_app_array.nominal_adaptive, + intrinsics=None, + ) + + +def test_write_failure_expression_not_defined(mock_app, test_tmp_dir): + fields_calculator = FieldsCalculator(mock_app) + + with patch.object(fields_calculator, "is_expression_defined", return_value=False): + output_file = test_tmp_dir / "expr.fld" + result = fields_calculator.write( + expression="my_expr", + output_file=str(output_file), + setup=mock_app.nominal_adaptive, + intrinsics=None, + ) + + assert result is False + + +def test_write_failure_file_extension(mock_app, test_tmp_dir): + fields_calculator = FieldsCalculator(mock_app) + + with patch.object(fields_calculator, "is_expression_defined", return_value=True): + output_file = test_tmp_dir / "expr.txt" + result = fields_calculator.write( + expression="my_expr", + output_file=str(output_file), + setup=mock_app.nominal_adaptive, + intrinsics=None, + ) + + assert result is False + + +@patch( + "ansys.aedt.core.visualization.post.fields_calculator.open_file", + mock_open(read_data="line1\nline2\nfinal_value\n"), +) +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.exists", return_value=True) +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.unlink") +@patch("ansys.aedt.core.visualization.post.fields_calculator.FieldsCalculator.write") +def test_evaluate(mock_fc_write, mock_path_unlink, mock_path_exists, mock_app): + """Test the evaluate method of FieldsCalculator class.""" + fields_calculator = FieldsCalculator(mock_app) + fields_calculator.is_expression_defined = MagicMock(return_value=True) + fields_calculator.ofieldsreporter = MagicMock() + + value = fields_calculator.evaluate(expression="expr", setup=None, intrinsics=None) + + assert value is not None + mock_fc_write.assert_called_once() + + +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.exists", return_value=True) +def test_export_with_sample_points_string(mock_path_exists, mock_app): + """Test the export method of FieldsCalculator class with sample points as a path.""" + fields_calculator = FieldsCalculator(mock_app) + mock_app.post.export_field_file = MagicMock(return_value="fake_output.fld") + + output = fields_calculator.export( + quantity="Mag_E", + output_file="fake_output.fld", + solution="Setup1 : LastAdaptive", + sample_points="points.csv", + ) + + assert output == "fake_output.fld" + + +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.exists", return_value=True) +def test_export_with_sample_points_list(mock_path_exists, mock_app): + """Test the export method of FieldsCalculator class with sample points as a list.""" + fields_calculator = FieldsCalculator(mock_app) + mock_app.post.export_field_file = MagicMock(return_value="fake_output.fld") + sample_points = [(0, 0, 0), (1, 1, 1), (2, 2, 2)] + + output = fields_calculator.export( + quantity="Mag_E", + output_file="fake_output.fld", + solution="Setup1 : LastAdaptive", + sample_points=sample_points, + ) + + assert output == "fake_output.fld" + + +def test_export_with_invalid_sample_points(mock_app): + """Test the export method of FieldsCalculator class with invalid sample points.""" + fields_calculator = FieldsCalculator(mock_app) + sample_points = 1 + + res = fields_calculator.export( + quantity="Mag_E", output_file="fake_output.fld", solution="Setup1 : LastAdaptive", sample_points=sample_points + ) + + assert not res + + +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.exists", return_value=True) +def test_export_grid_type(mock_path_exists, mock_app): + """Test the export method of FieldsCalculator class with different grid types.""" + fields_calculator = FieldsCalculator(mock_app) + mock_app.post.export_field_file_on_grid = MagicMock(return_value="fake_output.fld") + + grid_types = ["Cartesian", "Cylindrical", "Spherical"] + + for grid_type in grid_types: + output = fields_calculator.export( + quantity="Mag_E", output_file="fake_output.fld", solution="Setup1 : LastAdaptive", grid_type=grid_type + ) + assert output == "fake_output.fld" + + +def test_export_invalid_grid_type(mock_app): + """Test the export method of FieldsCalculator class with an invalid grid type.""" + fields_calculator = FieldsCalculator(mock_app) + mock_app._FieldsCalculator__app.post.export_field_file_on_grid = MagicMock(return_value="fake_output.fld") + grid_type = "invalid" + + res = fields_calculator.export( + quantity="Mag_E", output_file="fake_output.fld", solution="Setup1 : LastAdaptive", grid_type=grid_type + ) + + assert not res + + +def test_export_failure(mock_app): + """Test the export method of FieldsCalculator class when export fails.""" + fields_calculator = FieldsCalculator(mock_app) + + res = fields_calculator.export(quantity="Mag_E", output_file="fake_output.fld", solution="Setup1 : LastAdaptive") + + assert not res + + +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.is_file", return_value=False) +def test_load_expression_file_failure(mock_is_file, mock_app, test_tmp_dir): + """Test the failure of load_expression method of FieldsCalculator class.""" + fields_calculator = FieldsCalculator(mock_app) + input_file = test_tmp_dir / "expr_load.fld" + + assert not fields_calculator.load_expression_file(input_file) + + +@patch( + "ansys.aedt.core.visualization.post.fields_calculator.read_configuration_file", + return_value={"expr1": {"unit": "V"}}, +) +@patch("ansys.aedt.core.visualization.post.fields_calculator.Path.is_file", return_value=True) +def test_load_expression_file_success(mock_read_config, mock_app, test_tmp_dir): + """Test the success of load_expression method of FieldsCalculator class.""" + fields_calculator = FieldsCalculator(mock_app) + with patch.object(fields_calculator, "validate_expression", return_value=True): + result = fields_calculator.load_expression_file("fake.toml") + + assert "expr1" in result