diff --git a/doc/changelog.d/6364.miscellaneous.md b/doc/changelog.d/6364.miscellaneous.md new file mode 100644 index 00000000000..9ebed0d1f18 --- /dev/null +++ b/doc/changelog.d/6364.miscellaneous.md @@ -0,0 +1 @@ +Migrate choke designer extension diff --git a/src/ansys/aedt/core/extensions/hfss/choke_designer.py b/src/ansys/aedt/core/extensions/hfss/choke_designer.py index 50f5e062790..a167340ae0b 100644 --- a/src/ansys/aedt/core/extensions/hfss/choke_designer.py +++ b/src/ansys/aedt/core/extensions/hfss/choke_designer.py @@ -22,461 +22,367 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from pathlib import Path -import shutil -import tempfile +from dataclasses import dataclass +import os +import tkinter +from tkinter import filedialog +from tkinter import messagebox +from tkinter import ttk import ansys.aedt.core from ansys.aedt.core import Hfss import ansys.aedt.core.extensions +from ansys.aedt.core.extensions.misc import ExtensionCommonData +from ansys.aedt.core.extensions.misc import ExtensionHFSSCommon from ansys.aedt.core.extensions.misc import get_aedt_version from ansys.aedt.core.extensions.misc import get_arguments from ansys.aedt.core.extensions.misc import get_port from ansys.aedt.core.extensions.misc import get_process_id from ansys.aedt.core.extensions.misc import is_student from ansys.aedt.core.generic.file_utils import read_json -from ansys.aedt.core.generic.file_utils import write_configuration_file - -port = get_port() -version = get_aedt_version() -aedt_process_id = get_process_id() -is_student = is_student() - -default_config = { - "Number of Windings": {"1": True, "2": False, "3": False, "4": False}, - "Layer": {"Simple": True, "Double": False, "Triple": False}, - "Layer Type": {"Separate": True, "Linked": False}, - "Similar Layer": {"Similar": True, "Different": False}, - "Mode": {"Differential": True, "Common": False}, - "Wire Section": {"None": False, "Hexagon": False, "Octagon": False, "Circle": True}, - "Core": { - "Name": "Core", - "Material": "ferrite", - "Inner Radius": 20, - "Outer Radius": 30, - "Height": 10, - "Chamfer": 0.8, - }, - "Outer Winding": { - "Name": "Winding", - "Material": "copper", - "Inner Radius": 20, - "Outer Radius": 30, - "Height": 10, - "Wire Diameter": 1.5, - "Turns": 20, - "Coil Pit(deg)": 0.1, - "Occupation(%)": 0, - }, - "Mid Winding": { - "Turns": 25, - "Coil Pit(deg)": 0.1, - "Occupation(%)": 0, - }, - "Inner Winding": { - "Turns": 4, - "Coil Pit(deg)": 0.1, - "Occupation(%)": 0, - }, - "Settings": {"Units": "mm"}, - "Create Component": {"True": True, "False": False}, -} +from ansys.aedt.core.internal.errors import AEDTRuntimeError +from ansys.aedt.core.modeler.advanced_cad.choke import Choke -# Extension batch arguments -extension_arguments = {"choke_config": {}} -extension_description = "Choke Designer" - - -def frontend(): # pragma: no cover - import tkinter - from tkinter import filedialog - from tkinter import messagebox - from tkinter import ttk - - import PIL.Image - import PIL.ImageTk - - from ansys.aedt.core.extensions.misc import ExtensionTheme - - # Create UI - master = tkinter.Tk() - master.title(extension_description) - - # Detect if user close the UI - master.flag = False - - # Load the logo for the main window - icon_path = Path(ansys.aedt.core.extensions.__path__[0]) / "images" / "large" / "logo.png" - im = PIL.Image.open(icon_path) - photo = PIL.ImageTk.PhotoImage(im) - - # Set the icon for the main window - master.iconphoto(True, photo) - - # Configure style for ttk buttons - style = ttk.Style() - theme = ExtensionTheme() - - theme.apply_light_theme(style) - master.theme = "light" - - # Load initial configuration - config_dict = default_config.copy() - - # Main panel - main_frame = ttk.PanedWindow(master, orient=tkinter.HORIZONTAL, style="TPanedwindow") - main_frame.pack(fill=tkinter.BOTH, expand=True) - - # Left panel - left_frame = ttk.Frame(main_frame, width=350, style="PyAEDT.TFrame") - main_frame.add(left_frame, weight=1) - - selected_options = {} - - def create_boolean_options(parent, config): - for category, options in config.items(): - if category in ["Number of Windings", "Layer", "Layer Type", "Similar Layer", "Mode", "Create Component"]: - if isinstance(options, dict) and all(isinstance(v, bool) for v in options.values()): - group_frame = ttk.LabelFrame(parent, text=category, style="PyAEDT.TLabelframe") - group_frame.pack(fill=tkinter.X, padx=10, pady=5) - - selected_options[category] = tkinter.StringVar( - value=next((opt for opt, val in options.items() if val), "") - ) - - def update_config(cat, selected_option_u): - for key in config[cat]: - config[cat][key] = key == selected_option_u.get() - - for option, _ in options.items(): - btn = ttk.Radiobutton( - group_frame, - text=option, - variable=selected_options[category], - value=option, - style="PyAEDT.TRadiobutton", - command=lambda cat=category: update_config(cat, selected_options[cat]), - ) - btn.pack(anchor=tkinter.W, padx=5) - - create_boolean_options(left_frame, config_dict) +PORT = get_port() +VERSION = get_aedt_version() +AEDT_PROCESS_ID = get_process_id() +IS_STUDENT = is_student() - # Right panel - right_frame = ttk.Notebook(master, style="TNotebook") - main_frame.add(right_frame, weight=3) +# Extension batch arguments +EXTENSION_DEFAULT_ARGUMENTS = {"choke_config": {}} +EXTENSION_TITLE = "Choke Designer" - entries_dict = {} - def create_parameter_inputs(parent, config, category): - def update_config(cat, field, entry_widget): - """Update config_dict when the user changes an input.""" - try: - # Save numeric values as floats, others as strings - new_value = ( - float(entry_widget.get()) - if entry_widget.get().replace(".", "", 1).isdigit() - else entry_widget.get() - ) - config[cat][field] = new_value - except ValueError: - pass # Ignore invalid input +@dataclass +class ChokeDesignerExtensionData(ExtensionCommonData): + """Data class containing user input and computed data for Choke Designer.""" - for field, value in config[category].items(): - frame = ttk.Frame(parent, style="PyAEDT.TFrame") - frame.pack(fill=tkinter.X, padx=10, pady=2) + choke: Choke = None - label = ttk.Label(frame, text=field, width=20, style="PyAEDT.TLabel") - label.pack(side=tkinter.LEFT) - entry = ttk.Entry(frame, width=15, font=theme.default_font) - entry.insert(0, str(value)) - entry.pack(side=tkinter.LEFT, padx=5) - - entries_dict[(category, field)] = entry - - # Bind the `update_config` function to changes in the Entry widget - entry.bind("", lambda e, cat=category, fld=field, widget=entry: update_config(cat, fld, widget)) - - # Parameters - for tab_name in ["Core", "Outer Winding", "Mid Winding", "Inner Winding", "Settings"]: - tab = ttk.Frame(right_frame, style="PyAEDT.TFrame") - right_frame.add(tab, text=tab_name) - create_parameter_inputs(tab, config_dict, tab_name) +class ChokeDesignerExtension(ExtensionHFSSCommon): + """Extension for Choke Designer in AEDT.""" - def validate_configuration(config): + def __init__(self, withdraw: bool = False): + super().__init__( + EXTENSION_TITLE, + theme_color="light", + withdraw=withdraw, + add_custom_content=False, + toggle_row=0, + toggle_column=2, + ) + self.choke = Choke() + self.selected_options = {} + self.entries_dict = {} + self.flag = False + + # Category maps for UI organization + self.category_map = { + "Core": "core", + "Outer Winding": "outer_winding", + "Mid Winding": "mid_winding", + "Inner Winding": "inner_winding", + "Settings": "settings", + } + + self.boolean_categories = [ + "number_of_windings", + "layer", + "layer_type", + "similar_layer", + "mode", + "create_component", + "wire_section", + ] + + self.add_extension_content() + + def validate_configuration(self, choke): + """Validate choke configuration parameters.""" try: - if config["Core"]["Outer Radius"] <= config["Core"]["Inner Radius"]: - messagebox.showerror("Error", "Core outer radius must be greater than inner radius") + if choke.core["Outer Radius"] <= choke.core["Inner Radius"]: + messagebox.showerror( + "Error", + "Core outer radius must be greater than inner radius", + ) return False - - if config["Outer Winding"]["Outer Radius"] <= config["Outer Winding"]["Inner Radius"]: - messagebox.showerror("Error", "Winding outer radius must be greater than inner radius") + if choke.outer_winding["Outer Radius"] <= choke.outer_winding["Inner Radius"]: + messagebox.showerror( + "Error", + "Winding outer radius must be greater than inner radius", + ) return False - - if config["Core"]["Height"] <= 0: + if choke.core["Height"] <= 0: messagebox.showerror("Error", "Core height must be greater than 0") return False - - if config["Outer Winding"]["Wire Diameter"] <= 0: - messagebox.showerror("Error", "Wire diameter must be greater than 0") + if choke.outer_winding["Wire Diameter"] <= 0: + messagebox.showerror( + "Error", + "Wire diameter must be greater than 0", + ) return False return True - except (KeyError, TypeError) as e: + except (KeyError, TypeError, AttributeError) as e: messagebox.showerror("Error", f"Validation error: {str(e)}") return False - # Buttons - def save_configuration(): - if not validate_configuration(config_dict): - messagebox.showerror("Validation Error", "Please fix configuration errors before saving.") + def save_configuration(self): + """Save choke configuration to JSON file.""" + if not self.validate_configuration(self.choke): + messagebox.showerror( + "Validation Error", + "Please fix configuration errors before saving.", + ) return - file_path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON files", "*.json")]) + file_path = filedialog.asksaveasfilename( + defaultextension=".json", + filetypes=[("JSON files", "*.json")], + ) if file_path: try: - write_configuration_file(config_dict, file_path) + self.choke.export_to_json(file_path) messagebox.showinfo("Success", "Configuration saved successfully.") except Exception as e: - messagebox.showerror("Error", f"Failed to save configuration: {str(e)}") + messagebox.showerror( + "Error", + f"Failed to save configuration: {str(e)}", + ) - def load_configuration(): + def load_configuration(self): + """Load choke configuration from JSON file.""" file_path = filedialog.askopenfilename(filetypes=[("JSON files", "*.json")]) if file_path: try: new_config = read_json(file_path) - if not validate_configuration(new_config): - messagebox.showerror("Validation Error", "Please fix configuration errors before loading.") + new_choke = Choke.from_dict(new_config) + if not self.validate_configuration(new_choke): + messagebox.showerror( + "Validation Error", + "Please fix configuration errors before loading.", + ) return - for key in new_config: - if key in config_dict: - config_dict[key] = new_config[key] - update_radio_buttons() - update_entries() - messagebox.showinfo("Success", "Configuration loaded successfully.") + else: + self.choke = new_choke + self.update_radio_buttons() + self.update_entries() + messagebox.showinfo( + "Success", + "Configuration loaded successfully.", + ) except Exception as e: - messagebox.showerror("Error", f"Failed to save configuration: {str(e)}") - - def toggle_theme(): - if master.theme == "light": - set_dark_theme() - master.theme = "dark" - else: - set_light_theme() - master.theme = "light" - - def set_light_theme(): - master.configure(bg=theme.light["widget_bg"]) - theme.apply_light_theme(style) - change_theme_button.config(text="\u263d") - - def set_dark_theme(): - master.configure(bg=theme.dark["widget_bg"]) - theme.apply_dark_theme(style) - change_theme_button.config(text="\u2600") - - def update_radio_buttons(): - for category, options in config_dict.items(): - if isinstance(options, dict) and all(isinstance(v, bool) for v in options.values()): - selected_option = next((opt for opt, val in options.items() if val), "") - - if category in selected_options: - selected_options[category].set(selected_option) - - def update_entries(): - for category, options in config_dict.items(): - for field, value in options.items(): - entry_widget = entries_dict.get((category, field)) - - if entry_widget: - entry_widget.delete(0, tkinter.END) - entry_widget.insert(0, str(value)) - - button_frame = ttk.Frame(master, style="PyAEDT.TFrame", relief=tkinter.SUNKEN, borderwidth=2) - button_frame.pack(fill=tkinter.X, pady=0) + messagebox.showerror( + "Error", + f"Failed to load configuration: {str(e)}", + ) - save_button = ttk.Button( - button_frame, text="Save Configuration", command=save_configuration, style="PyAEDT.TButton" - ) - load_button = ttk.Button( - button_frame, text="Load Configuration", command=load_configuration, style="PyAEDT.TButton" - ) - change_theme_button = ttk.Button(button_frame, text="\u263d", command=toggle_theme, style="PyAEDT.TButton") - save_button.pack(side=tkinter.LEFT, padx=5) - load_button.pack(side=tkinter.LEFT, padx=5) - change_theme_button.pack(side=tkinter.RIGHT, padx=5, pady=40) + def update_config(self, category, selected_option): + """Update boolean configuration options.""" + choke_options = getattr(self.choke, category) + for key in choke_options: + choke_options[key] = key == selected_option.get() - def callback(): - master.flag = True - if validate_configuration(config_dict): - master.destroy() + def update_parameter_config(self, attr_name, field, entry_widget): + """Update parameter configuration from entry widget.""" + try: + entry_value = entry_widget.get() + new_value = float(entry_value) if entry_value.replace(".", "", 1).isdigit() else entry_value + getattr(self.choke, attr_name)[field] = new_value + except (ValueError, AttributeError): + pass + + def update_radio_buttons(self): + """Update radio button selections based on current choke configuration.""" + for category in self.boolean_categories: + if hasattr(self.choke, category): + options = getattr(self.choke, category) + if isinstance(options, dict) and all(isinstance(v, bool) for v in options.values()): + selected_option = next( + (opt for opt, val in options.items() if val), + "", + ) + if category in self.selected_options: + self.selected_options[category].set(selected_option) + + def update_entries(self): + """Update entry widgets based on current choke configuration.""" + for category_name, attr_name in self.category_map.items(): + if hasattr(self.choke, attr_name): + options = getattr(self.choke, attr_name) + for field, value in options.items(): + entry_widget = self.entries_dict.get((category_name, field)) + if entry_widget: + entry_widget.delete(0, tkinter.END) + entry_widget.insert(0, str(value)) + + def callback(self): + """Callback function for Export to HFSS button.""" + self.flag = True + if self.validate_configuration(self.choke): + self.data = ChokeDesignerExtensionData(choke=self.choke) + self.root.destroy() + + def create_boolean_options(self, parent): + """Create boolean option radio buttons.""" + for category in self.boolean_categories: + if hasattr(self.choke, category): + options = getattr(self.choke, category) + if isinstance(options, dict) and all(isinstance(v, bool) for v in options.values()): + group_frame = ttk.LabelFrame( + parent, + text=category.replace("_", " ").title(), + style="PyAEDT.TLabelframe", + ) + group_frame.pack(fill=tkinter.X, padx=10, pady=5) + self.selected_options[category] = tkinter.StringVar( + value=next( + (opt for opt, val in options.items() if val), + "", + ) + ) - export_hfss = ttk.Button(button_frame, text="Export to HFSS", command=callback, style="PyAEDT.TButton") - export_hfss.pack(side=tkinter.LEFT, padx=5) + for option, _ in options.items(): + btn = ttk.Radiobutton( + group_frame, + text=option, + variable=self.selected_options[category], + value=option, + style="PyAEDT.TRadiobutton", + command=lambda cat=category: self.update_config(cat, self.selected_options[cat]), + ) + btn.pack(anchor=tkinter.W, padx=5) - tkinter.mainloop() + def create_parameter_inputs(self, parent, category_name): + """Create parameter input widgets for a category.""" + # Get the attribute name from the category name + attr_name = self.category_map.get(category_name) + if not attr_name or not hasattr(self.choke, attr_name): + return - choke_config = {} - if master.flag: - choke_config = {"choke_config": config_dict} - return choke_config + category_data = getattr(self.choke, attr_name) + for field, value in category_data.items(): + frame = ttk.Frame(parent, style="PyAEDT.TFrame") + frame.pack(fill=tkinter.X, padx=10, pady=2) + label = ttk.Label(frame, text=field, width=20, style="PyAEDT.TLabel") + label.pack(side=tkinter.LEFT) + entry = ttk.Entry(frame, width=15, font=self.theme.default_font) + entry.insert(0, str(value)) + entry.pack(side=tkinter.LEFT, padx=5) + self.entries_dict[(category_name, field)] = entry + entry.bind( + "", + lambda e, attr=attr_name, fld=field, widget=entry: self.update_parameter_config(attr, fld, widget), + ) + + def add_extension_content(self): + """Add custom content to the extension UI.""" + master = self.root + # Main panel + main_frame = ttk.PanedWindow(master, orient=tkinter.HORIZONTAL, style="TPanedwindow") + main_frame.grid(row=0, column=0, sticky="nsew") + master.grid_rowconfigure(0, weight=1) + master.grid_columnconfigure(0, weight=1) + + # Left panel + left_frame = ttk.Frame(main_frame, width=350, style="PyAEDT.TFrame") + main_frame.add(left_frame, weight=1) + + self.create_boolean_options(left_frame) + + # Right panel + right_frame = ttk.Notebook(master, style="TNotebook") + main_frame.add(right_frame, weight=3) + + for tab_name in [ + "Core", + "Outer Winding", + "Mid Winding", + "Inner Winding", + "Settings", + ]: + tab = ttk.Frame(right_frame, style="PyAEDT.TFrame") + right_frame.add(tab, text=tab_name) + self.create_parameter_inputs(tab, tab_name) + + button_frame = ttk.Frame( + master, + style="PyAEDT.TFrame", + relief=tkinter.SUNKEN, + borderwidth=2, + ) + button_frame.grid(row=1, column=0, sticky="ew") + save_button = ttk.Button( + button_frame, + text="Save Configuration", + command=self.save_configuration, + style="PyAEDT.TButton", + ) + load_button = ttk.Button( + button_frame, + text="Load Configuration", + command=self.load_configuration, + style="PyAEDT.TButton", + ) + save_button.pack(side=tkinter.LEFT, padx=5) + load_button.pack(side=tkinter.LEFT, padx=5) + + export_hfss = ttk.Button( + button_frame, + text="Export to HFSS", + command=self.callback, + style="PyAEDT.TButton", + ) + export_hfss.pack(side=tkinter.LEFT, padx=5) -def main(extension_args): - choke_config = extension_args["choke_config"] +def main(data): + """Main function to run the choke designer extension.""" + choke = data.choke app = ansys.aedt.core.Desktop( new_desktop=False, - version=version, - port=port, - aedt_process_id=aedt_process_id, - student_version=is_student, + version=VERSION, + port=PORT, + aedt_process_id=AEDT_PROCESS_ID, + student_version=IS_STUDENT, ) - active_project = app.active_project() - hfss = None - if not active_project: + if not active_project: # pragma: no cover hfss = Hfss() hfss.save_project() active_project = app.active_project() - active_design = app.active_design() - project_name = active_project.GetName() - design_name = None if active_design: design_name = active_design.GetName() - if not hfss: # pragma: no cover if app.design_type(project_name, design_name) == "HFSS": hfss = Hfss(project_name, design_name) else: hfss = Hfss() hfss.save_project() - hfss.solution_type = "Terminal" - - # Create temporary directory for JSON file - temp_dir = Path(tempfile.mkdtemp()) - json_path = temp_dir / "choke_params.json" - - write_configuration_file(choke_config, str(json_path)) - - # Verify parameters - dictionary_values = hfss.modeler.check_choke_values(str(json_path), create_another_file=False) - # Create choke geometry - list_object = hfss.modeler.create_choke(str(json_path)) + list_object = choke.create_choke(app=hfss) if not list_object: # pragma: no cover - app.logger.error("No object associated to chocke creation.") - if temp_dir.exists(): - shutil.rmtree(temp_dir, ignore_errors=True) - - if not extension_args["is_test"]: # pragma: no cover + app.logger.error("No object associated to choke creation.") + if "PYTEST_CURRENT_TEST" not in os.environ: # pragma: no cover app.release_desktop(False, False) - return False - - # Get winding objects - first_winding_list = list_object[2] - - # Get second winding list if it exists - second_winding_list = list_object[3] if len(list_object) > 3 else None - - # Create ground plane - ground_radius = 1.2 * dictionary_values[1]["Outer Winding"]["Outer Radius"] - ground_position = [0, 0, first_winding_list[1][0][2] - 2] - ground = hfss.modeler.create_circle("XY", ground_position, ground_radius, name="GND", material="copper") - hfss.assign_finite_conductivity(ground.name, is_infinite_ground=True) - ground.transparency = 0.9 - - # Create mesh operation - cylinder_height = 2.5 * dictionary_values[1]["Outer Winding"]["Height"] - cylinder_position = [0, 0, first_winding_list[1][0][2] - 4] - mesh_operation_cylinder = hfss.modeler.create_cylinder( - "XY", - cylinder_position, - ground_radius, - cylinder_height, - num_sides=36, - name="mesh_cylinder", - ) - - # Create port positions list based on available windings - port_position_list = [ - # First winding start position - [ - first_winding_list[1][0][0], - first_winding_list[1][0][1], - first_winding_list[1][0][2] - 1, - ], - # First winding end position - [ - first_winding_list[1][-1][0], - first_winding_list[1][-1][1], - first_winding_list[1][-1][2] - 1, - ], - ] - - # Add second winding ports if it exists - if second_winding_list: # pragma: no cover - port_position_list.extend( - [ - # Second winding start position - [ - second_winding_list[1][0][0], - second_winding_list[1][0][1], - second_winding_list[1][0][2] - 1, - ], - # Second winding end position - [ - second_winding_list[1][-1][0], - second_winding_list[1][-1][1], - second_winding_list[1][-1][2] - 1, - ], - ] - ) - - # Port dimensions - wire_diameter = dictionary_values[1]["Outer Winding"]["Wire Diameter"] - port_dimension_list = [2, wire_diameter] - - # Create lumped ports - for i, position in enumerate(port_position_list): - # Create port sheet - sheet = hfss.modeler.create_rectangle("XZ", position, port_dimension_list, name=f"sheet_port_{i + 1}") - - # Move sheet to correct position relative to wire - sheet.move([-wire_diameter / 2, 0, -1]) + raise AEDTRuntimeError("No object associated to choke creation.") - # Create lumped port - hfss.lumped_port( - assignment=sheet.name, - name=f"port_{i + 1}", - reference=[ground], - ) - - # Assign mesh operation - hfss.mesh.assign_length_mesh( - [mesh_operation_cylinder], - maximum_length=15, - maximum_elements=None, - name="choke_mesh", - ) + ground = choke.create_ground(app=hfss) + choke.create_mesh(app=hfss) + choke.create_ports(ground, app=hfss) - # Create 3D Component - if choke_config["Create Component"]["True"]: + if choke.create_component["True"]: # pragma: no cover hfss.modeler.replace_3dcomponent() - - # Create region hfss.modeler.create_region(pad_percent=1000) - - # Create setup setup = hfss.create_setup("Setup1", setup_type="HFSSDriven") setup.props["Frequency"] = "50MHz" setup.props["MaximumPasses"] = 10 - - # Create frequency sweep hfss.create_linear_count_sweep( setup=setup.name, units="MHz", @@ -487,31 +393,21 @@ def main(extension_args): sweep_type="Interpolating", save_fields=False, ) - - # Save project hfss.save_project() - - if temp_dir.exists(): - shutil.rmtree(temp_dir, ignore_errors=True) - - if extension_args["is_test"]: + if "PYTEST_CURRENT_TEST" in os.environ: # pragma: no cover hfss.close_project() - - if not extension_args["is_test"]: # pragma: no cover + if "PYTEST_CURRENT_TEST" not in os.environ: # pragma: no cover app.release_desktop(False, False) return True if __name__ == "__main__": # pragma: no cover - args = get_arguments(extension_arguments, extension_description) - - # Open UI + args = get_arguments(EXTENSION_DEFAULT_ARGUMENTS, EXTENSION_TITLE) if not args["is_batch"]: # pragma: no cover - output = frontend() - if output: - for output_name, output_value in output.items(): - if output_name in extension_arguments: - args[output_name] = output_value - main(args) + extension = ChokeDesignerExtension(withdraw=False) + tkinter.mainloop() + if extension.data is not None: + main(extension.data) else: - main(args) + data = ChokeDesignerExtensionData() + main(data) diff --git a/src/ansys/aedt/core/modeler/advanced_cad/choke.py b/src/ansys/aedt/core/modeler/advanced_cad/choke.py new file mode 100644 index 00000000000..c17a93e1882 --- /dev/null +++ b/src/ansys/aedt/core/modeler/advanced_cad/choke.py @@ -0,0 +1,415 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 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 dataclasses import dataclass +from dataclasses import field +from pathlib import Path +import shutil +import tempfile +from typing import Any +from typing import Dict + +from ansys.aedt.core.generic.file_utils import write_configuration_file +from ansys.aedt.core.generic.general_methods import pyaedt_function_handler + +CHOKE_DEFAULT_PARAMETERS = { + "Number of Windings": { + "1": True, + "2": False, + "3": False, + "4": False, + }, + "Layer": {"Simple": True, "Double": False, "Triple": False}, + "Layer Type": {"Separate": True, "Linked": False}, + "Similar Layer": {"Similar": True, "Different": False}, + "Mode": {"Differential": True, "Common": False}, + "Wire Section": { + "None": False, + "Hexagon": False, + "Octagon": False, + "Circle": True, + }, + "Core": { + "Name": "Core", + "Material": "ferrite", + "Inner Radius": 20, + "Outer Radius": 30, + "Height": 10, + "Chamfer": 0.8, + }, + "Outer Winding": { + "Name": "Winding", + "Material": "copper", + "Inner Radius": 20, + "Outer Radius": 30, + "Height": 10, + "Wire Diameter": 1.5, + "Turns": 20, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + "Mid Winding": { + "Turns": 25, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + "Inner Winding": { + "Turns": 4, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + "Settings": {"Units": "mm"}, + "Create Component": {"True": True, "False": False}, +} + + +@dataclass +class Choke: + """Class to create chokes in AEDT. + + Parameters + ---------- + name : str, optional + Name of the choke. The default is ``"Choke"``. + number_of_windings : Dict[str, bool], optional + Number of windings configuration. + layer : Dict[str, bool], optional + Layer configuration. + layer_type : Dict[str, bool], optional + Layer type configuration. + similar_layer : Dict[str, bool], optional + Similar layer configuration. + mode : Dict[str, bool], optional + Mode configuration. + wire_section : Dict[str, bool], optional + Wire section configuration. + core : Dict[str, Any], optional + Core configuration. + outer_winding : Dict[str, Any], optional + Outer winding configuration. + mid_winding : Dict[str, Any], optional + Mid winding configuration. + inner_winding : Dict[str, Any], optional + Inner winding configuration. + settings : Dict[str, Any], optional + Settings configuration. + create_component : Dict[str, bool], optional + Create component configuration. + + Examples + -------- + Create a basic choke with default parameters: + + >>> from ansys.aedt.core.modeler.advanced_cad.choke import Choke + >>> choke = Choke() + >>> choke.name = "my_choke" + + Create a choke with custom core dimensions: + + >>> choke = Choke() + >>> choke.core["Inner Radius"] = 15 + >>> choke.core["Outer Radius"] = 25 + >>> choke.core["Height"] = 12 + >>> choke.core["Material"] = "ferrite" + + Create a choke with custom winding configuration: + + >>> choke = Choke() + >>> choke.outer_winding["Wire Diameter"] = 2.0 + >>> choke.outer_winding["Turns"] = 30 + >>> choke.outer_winding["Material"] = "copper" + + Configure number of windings: + + >>> choke = Choke() + >>> # Set to 2 windings + >>> choke.number_of_windings = {"1": False, "2": True, "3": False, "4": False} + + Set wire section type: + + >>> choke = Choke() + >>> # Use hexagonal wire section + >>> choke.wire_section = {"None": False, "Hexagon": True, "Octagon": False, "Circle": False} + + Create choke in HFSS application: + + >>> import ansys.aedt.core as pyaedt + >>> hfss = pyaedt.Hfss() + >>> choke = Choke() + >>> objects = choke.create_choke(app=hfss) + >>> ground = choke.create_ground(app=hfss) + >>> mesh = choke.create_mesh(app=hfss) + >>> ports = choke.create_ports(ground, app=hfss) + + Load choke configuration from JSON file: + + >>> from ansys.aedt.core.generic.file_utils import read_json + >>> config_data = read_json("choke_config.json") + >>> choke = Choke.from_dict(config_data) + + Export choke configuration to JSON file: + + >>> choke = Choke() + >>> choke.export_to_json("my_choke_config.json") + + Create a differential mode choke: + + >>> choke = Choke() + >>> choke.mode = {"Differential": True, "Common": False} + >>> choke.layer_type = {"Separate": True, "Linked": False} + + Create a triple layer choke: + + >>> choke = Choke() + >>> choke.layer = {"Simple": False, "Double": False, "Triple": True} + >>> choke.inner_winding["Turns"] = 10 + >>> choke.mid_winding["Turns"] = 15 + >>> choke.outer_winding["Turns"] = 20 + """ + + name: str = "choke" + number_of_windings: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Number of Windings"]) + layer: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Layer"]) + layer_type: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Layer Type"]) + similar_layer: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Similar Layer"]) + mode: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Mode"]) + wire_section: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Wire Section"]) + core: Dict[str, Any] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Core"]) + outer_winding: Dict[str, Any] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Outer Winding"]) + mid_winding: Dict[str, Any] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Mid Winding"]) + inner_winding: Dict[str, Any] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Inner Winding"]) + settings: Dict[str, Any] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Settings"]) + create_component: Dict[str, bool] = field(default_factory=lambda: CHOKE_DEFAULT_PARAMETERS["Create Component"]) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Choke": + """Create a Choke instance from a dictionary. + + Parameters + ---------- + data : Dict[str, Any] + Dictionary containing choke configuration data. + + Returns + ------- + Choke + Choke instance created from the dictionary. + """ + return cls( + number_of_windings=data["Number of Windings"], + layer=data["Layer"], + layer_type=data["Layer Type"], + similar_layer=data["Similar Layer"], + mode=data["Mode"], + wire_section=data["Wire Section"], + core=data["Core"], + outer_winding=data["Outer Winding"], + mid_winding=data["Mid Winding"], + inner_winding=data["Inner Winding"], + settings=data["Settings"], + create_component=data["Create Component"], + ) + + @property + def choke_parameters(self) -> dict: + """Get the choke parameters as a dictionary + + Returns + ------- + dict + Dictionary of choke parameters. + """ + return { + "Number of Windings": self.number_of_windings, + "Layer": self.layer, + "Layer Type": self.layer_type, + "Similar Layer": self.similar_layer, + "Mode": self.mode, + "Wire Section": self.wire_section, + "Core": self.core, + "Outer Winding": self.outer_winding, + "Mid Winding": self.mid_winding, + "Inner Winding": self.inner_winding, + "Settings": self.settings, + "Create Component": self.create_component, + } + + def export_to_json(self, file_path: str) -> bool: + """Export choke configuration to JSON file. + + Parameters + ---------- + file_path : str + Path to the JSON file to save the configuration. + + Returns + ------- + bool + True if export was successful, False otherwise. + + Raises + ------ + Exception + If there's an error during file writing. + """ + try: + write_configuration_file(self.choke_parameters, file_path) + return True + except Exception as e: + raise Exception(f"Failed to export configuration: {str(e)}") + + @pyaedt_function_handler() + def create_choke(self, app=None): + """Create a choke. + + Returns + ------- + list + List of objects created. + """ + # Create temporary directory for JSON file + temp_dir = Path(tempfile.mkdtemp()) + json_path = temp_dir / "choke_params.json" + write_configuration_file(self.choke_parameters, str(json_path)) + # Verify parameters + _ = app.modeler.check_choke_values(str(json_path), create_another_file=False) + # Create choke geometry + list_object = app.modeler.create_choke(str(json_path)) + shutil.rmtree(temp_dir, ignore_errors=True) + self.list_object = list_object + return list_object + + @pyaedt_function_handler() + def create_ground(self, app): + """Create the ground plane. + + Returns + ------- + :class:`ansys.aedt.core.modeler.cad.object3d.Object3d` + Ground object. + """ + first_winding_list = self.list_object[2] + ground_radius = 1.2 * self.outer_winding["Outer Radius"] + ground_position = [0, 0, first_winding_list[1][0][2] - 2] + ground = app.modeler.create_circle( + "XY", + ground_position, + ground_radius, + name="GND", + material="copper", + ) + app.assign_finite_conductivity(ground.name, is_infinite_ground=True) + ground.transparency = 0.9 + return ground + + @pyaedt_function_handler() + def create_mesh(self, app): + """Create the mesh. + + Returns + ------- + :class:`ansys.aedt.core.modules.mesh_helpers.MeshOperation` + Mesh operation object. + """ + first_winding_list = self.list_object[2] + ground_radius = 1.2 * self.outer_winding["Outer Radius"] + cylinder_height = 2.5 * self.outer_winding["Height"] + cylinder_position = [0, 0, first_winding_list[1][0][2] - 4] + mesh_operation_cylinder = app.modeler.create_cylinder( + "XY", + cylinder_position, + ground_radius, + cylinder_height, + num_sides=36, + name="mesh_cylinder", + ) + mesh = app.mesh.assign_length_mesh( + [mesh_operation_cylinder], + maximum_length=15, + maximum_elements=None, + name="choke_mesh", + ) + return mesh + + @pyaedt_function_handler() + def create_ports(self, ground, app): + """Create the ports. + + Parameters + ---------- + ground : :class:`ansys.aedt.core.modeler.cad.object3d` + Ground object. + + Returns + ------- + list + List of ports. + """ + first_winding_list = self.list_object[2] + second_winding_list = self.list_object[3] if len(self.list_object) > 3 else None + port_position_list = [ + [ + first_winding_list[1][0][0], + first_winding_list[1][0][1], + first_winding_list[1][0][2] - 1, + ], + [ + first_winding_list[1][-1][0], + first_winding_list[1][-1][1], + first_winding_list[1][-1][2] - 1, + ], + ] + if second_winding_list: # pragma: no cover + port_position_list.extend( + [ + [ + second_winding_list[1][0][0], + second_winding_list[1][0][1], + second_winding_list[1][0][2] - 1, + ], + [ + second_winding_list[1][-1][0], + second_winding_list[1][-1][1], + second_winding_list[1][-1][2] - 1, + ], + ] + ) + wire_diameter = self.outer_winding["Wire Diameter"] + port_dimension_list = [2, wire_diameter] + ports = [] + for i, position in enumerate(port_position_list): + sheet = app.modeler.create_rectangle( + "XZ", + position, + port_dimension_list, + name=f"sheet_port_{i + 1}", + ) + sheet.move([-wire_diameter / 2, 0, -1]) + port = app.lumped_port( + assignment=sheet.name, + name=f"port_{i + 1}", + reference=[ground], + ) + ports.append(port) + return ports diff --git a/tests/system/extensions/test_45_extensions.py b/tests/system/extensions/test_45_extensions.py index 3b0a3cde773..bd2530f15b6 100644 --- a/tests/system/extensions/test_45_extensions.py +++ b/tests/system/extensions/test_45_extensions.py @@ -170,68 +170,6 @@ def test_15_import_asc(self, local_scratch, add_app): assert execinfo.args[0] == "File does not exist." aedtapp.close_project() - def test_17_choke_designer(self, local_scratch): - from ansys.aedt.core.extensions.hfss.choke_designer import main - - choke_config = { - "Number of Windings": { - "1": True, - "2": False, - "3": False, - "4": False, - }, - "Layer": { - "Simple": True, - "Double": False, - "Triple": False, - }, - "Layer Type": {"Separate": True, "Linked": False}, - "Similar Layer": {"Similar": True, "Different": False}, - "Mode": {"Differential": True, "Common": False}, - "Wire Section": { - "None": False, - "Hexagon": False, - "Octagon": False, - "Circle": True, - }, - "Core": { - "Name": "Core", - "Material": "ferrite", - "Inner Radius": 20, - "Outer Radius": 30, - "Height": 10, - "Chamfer": 0.8, - }, - "Outer Winding": { - "Name": "Winding", - "Material": "copper", - "Inner Radius": 20, - "Outer Radius": 30, - "Height": 10, - "Wire Diameter": 1.5, - "Turns": 20, - "Coil Pit(deg)": 0.1, - "Occupation(%)": 0, - }, - "Mid Winding": { - "Turns": 25, - "Coil Pit(deg)": 0.1, - "Occupation(%)": 0, - }, - "Inner Winding": { - "Turns": 4, - "Coil Pit(deg)": 0.1, - "Occupation(%)": 0, - }, - "Settings": {"Units": "mm"}, - "Create Component": {"True": True, "False": False}, - } - extension_args = { - "is_test": True, - "choke_config": choke_config, - } - assert main(extension_args) - @pytest.mark.skipif(is_linux, reason="Not supported in Linux.") def test_18_via_merging(self, local_scratch): from ansys.aedt.core.extensions.hfss3dlayout.via_clustering_extension import main diff --git a/tests/system/extensions/test_choke_designer.py b/tests/system/extensions/test_choke_designer.py new file mode 100644 index 00000000000..12479d85a60 --- /dev/null +++ b/tests/system/extensions/test_choke_designer.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 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 ansys.aedt.core import Hfss +from ansys.aedt.core.extensions.hfss.choke_designer import ChokeDesignerExtension +from ansys.aedt.core.extensions.hfss.choke_designer import ChokeDesignerExtensionData +from ansys.aedt.core.extensions.hfss.choke_designer import main +from ansys.aedt.core.modeler.advanced_cad.choke import Choke + + +def test_choke_designer_main_function(add_app): + """Test the main function of the Choke Designer extension.""" + # Create HFSS application for testing environment + add_app( + application=Hfss, + project_name="choke_test", + design_name="design1", + ) + + # Create extension with default choke + extension = ChokeDesignerExtension(withdraw=True) + choke = extension.choke + + # Create data object + data = ChokeDesignerExtensionData(choke=choke) + + # Test main function + result = main(data) + assert result is True + + # Note: main() creates its own HFSS instance, so we need to check that instance. + # This test verifies that main() completes successfully + + +def test_choke_designer_custom_config(add_app): + """Test Choke Designer with custom configuration.""" + # Create HFSS application for testing environment + add_app( + application=Hfss, + project_name="choke_custom", + design_name="design1", + ) + + # Create custom choke configuration + custom_choke = Choke() + custom_choke.core["Material"] = "custom_ferrite" + custom_choke.core["Inner Radius"] = 15 + custom_choke.core["Outer Radius"] = 25 + custom_choke.outer_winding["Turns"] = 15 + custom_choke.outer_winding["Wire Diameter"] = 1.0 + + # Create data object with custom choke + data = ChokeDesignerExtensionData(choke=custom_choke) + + # Test main function with custom configuration + result = main(data) + assert result is True + + # Note: Objects are created inside main() function in its own + # HFSS instance. This test verifies successful execution. diff --git a/tests/unit/extensions/test_choke_designer.py b/tests/unit/extensions/test_choke_designer.py new file mode 100644 index 00000000000..50bfbc25270 --- /dev/null +++ b/tests/unit/extensions/test_choke_designer.py @@ -0,0 +1,543 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 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 tempfile +import tkinter +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from ansys.aedt.core.extensions.hfss.choke_designer import ChokeDesignerExtension +from ansys.aedt.core.extensions.hfss.choke_designer import ChokeDesignerExtensionData +from ansys.aedt.core.modeler.advanced_cad.choke import Choke + + +@pytest.fixture +def sample_choke_config(): + """Fixture to provide a sample choke configuration.""" + return { + "Number of Windings": { + "1": True, + "2": False, + "3": False, + "4": False, + }, + "Layer": {"Simple": True, "Double": False, "Triple": False}, + "Layer Type": {"Separate": True, "Linked": False}, + "Similar Layer": {"Similar": True, "Different": False}, + "Mode": {"Differential": True, "Common": False}, + "Wire Section": { + "None": False, + "Hexagon": False, + "Octagon": False, + "Circle": True, + }, + "Core": { + "Name": "Core", + "Material": "ferrite", + "Inner Radius": 15, + "Outer Radius": 25, + "Height": 12, + "Chamfer": 0.8, + }, + "Outer Winding": { + "Name": "Winding", + "Material": "copper", + "Inner Radius": 16, + "Outer Radius": 24, + "Height": 10, + "Wire Diameter": 1.5, + "Turns": 20, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + "Mid Winding": { + "Turns": 25, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + "Inner Winding": { + "Turns": 4, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + "Settings": {"Units": "mm"}, + "Create Component": {"True": True, "False": False}, + } + + +@pytest.fixture +def invalid_choke_config(): + """Fixture to provide an invalid choke configuration.""" + return { + "Core": { + "Name": "Core", + "Material": "ferrite", + "Inner Radius": 30, # Invalid: larger than outer radius + "Outer Radius": 20, + "Height": -5, # Invalid: negative height + "Chamfer": 0.8, + }, + "Outer Winding": { + "Name": "Winding", + "Material": "copper", + "Inner Radius": 25, # Invalid: larger than outer radius + "Outer Radius": 20, + "Height": 10, + "Wire Diameter": -1.5, # Invalid: negative diameter + "Turns": 20, + "Coil Pit(deg)": 0.1, + "Occupation(%)": 0, + }, + } + + +def test_choke_designer_extension_data_class(): + """Test ChokeDesignerExtensionData class.""" + choke = Choke() + data = ChokeDesignerExtensionData(choke=choke) + + assert data.choke is choke + assert isinstance(data.choke, Choke) + + +def test_choke_designer_extension_with_custom_choke(mock_hfss_app): + """Test instantiation with a custom choke configuration.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Modify choke configuration + extension.choke.core["Inner Radius"] = 15 + extension.choke.core["Outer Radius"] = 25 + extension.choke.number_of_windings = { + "1": False, + "2": True, + "3": False, + "4": False, + } + + assert extension.choke.core["Inner Radius"] == 15 + assert extension.choke.core["Outer Radius"] == 25 + assert extension.choke.number_of_windings["2"] is True + assert extension.choke.number_of_windings["1"] is False + + extension.root.destroy() + + +def test_validate_configuration_valid(mock_hfss_app, sample_choke_config): + extension = ChokeDesignerExtension(withdraw=True) + extension.choke = Choke.from_dict(sample_choke_config) + with patch("tkinter.messagebox.showerror") as mock_error: + # Should not raise an error + assert extension.validate_configuration(extension.choke) is True + mock_error.assert_not_called() + + extension.root.destroy() + + +def test_validate_configuration_missing_attributes(mock_hfss_app): + """Test validation with missing attributes.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Create an incomplete choke object + incomplete_choke = MagicMock() + del incomplete_choke.core # Remove core attribute + + with patch("tkinter.messagebox.showerror") as mock_error: + result = extension.validate_configuration(incomplete_choke) + assert result is False + mock_error.assert_called_once() + args, kwargs = mock_error.call_args + assert "Validation error:" in args[1] + + extension.root.destroy() + + +@patch("tkinter.filedialog.asksaveasfilename") +def test_save_configuration_success(mock_filedialog, sample_choke_config, mock_hfss_app): + """Test successful configuration save.""" + with tempfile.NamedTemporaryFile(suffix=".json", prefix="test_config_", delete=False) as temp_file: + temp_file_path = temp_file.name + mock_filedialog.return_value = temp_file_path + + extension = ChokeDesignerExtension(withdraw=True) + extension.choke = Choke.from_dict(sample_choke_config) + + with patch.object(extension.choke, "export_to_json") as mock_export: + with patch("tkinter.messagebox.showinfo") as mock_info: + extension.save_configuration() + + mock_export.assert_called_once_with(temp_file_path) + mock_info.assert_called_once_with("Success", "Configuration saved successfully.") + + extension.root.destroy() + + +@patch("tkinter.filedialog.asksaveasfilename") +def test_save_configuration_validation_failure(mock_filedialog, mock_hfss_app): + """Test save configuration with validation failure.""" + with tempfile.NamedTemporaryFile(suffix=".json", prefix="test_config_", delete=False) as temp_file: + temp_file_path = temp_file.name + mock_filedialog.return_value = temp_file_path + + extension = ChokeDesignerExtension(withdraw=True) + extension.choke.core["Inner Radius"] = 30 + extension.choke.core["Outer Radius"] = 20 # Invalid configuration + + with patch("tkinter.messagebox.showerror") as mock_error: + extension.save_configuration() + + # Should be called twice: once from validate_configuration, once from save_configuration + assert mock_error.call_count == 2 + + extension.root.destroy() + + +@patch("tkinter.filedialog.asksaveasfilename") +def test_save_configuration_export_failure(mock_filedialog, sample_choke_config, mock_hfss_app): + """Test save configuration with export failure.""" + with tempfile.NamedTemporaryFile(suffix=".json", prefix="test_config_", delete=False) as temp_file: + temp_file_path = temp_file.name + mock_filedialog.return_value = temp_file_path + + extension = ChokeDesignerExtension(withdraw=True) + extension.choke = Choke.from_dict(sample_choke_config) + + with patch.object( + extension.choke, + "export_to_json", + side_effect=Exception("Export failed"), + ): + with patch("tkinter.messagebox.showerror") as mock_error: + extension.save_configuration() + + mock_error.assert_called_once() + args, kwargs = mock_error.call_args + assert "Failed to save configuration" in args[1] + assert "Export failed" in args[1] + + extension.root.destroy() + + +@patch("tkinter.filedialog.askopenfilename") +def test_load_configuration_no_file_selected(mock_filedialog, mock_hfss_app): + """Test load configuration when no file is selected.""" + mock_filedialog.return_value = "" # User cancels file dialog + + extension = ChokeDesignerExtension(withdraw=True) + + with patch("ansys.aedt.core.generic.file_utils.read_json") as mock_read_json: + extension.load_configuration() + mock_read_json.assert_not_called() + + extension.root.destroy() + + +@patch("tkinter.filedialog.askopenfilename") +@patch("ansys.aedt.core.generic.file_utils.read_json") +def test_load_configuration_validation_failure( + mock_read_json, + mock_filedialog, + invalid_choke_config, + mock_hfss_app, +): + """Test load configuration with validation failure.""" + with tempfile.NamedTemporaryFile(suffix=".json", prefix="test_config_", delete=False) as temp_file: + temp_file_path = temp_file.name + mock_filedialog.return_value = temp_file_path + mock_read_json.return_value = invalid_choke_config + + extension = ChokeDesignerExtension(withdraw=True) + + with patch("tkinter.messagebox.showerror") as mock_error: + extension.load_configuration() + + # Should be called twice: once from validate_configuration, once from load_configuration + assert mock_error.call_count >= 1 + + extension.root.destroy() + + +def test_update_config(mock_hfss_app): + """Test updating boolean configuration options.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Create a StringVar to simulate radio button selection + selected_option = tkinter.StringVar(value="2") + + # Test updating number_of_windings + extension.update_config("number_of_windings", selected_option) + + assert extension.choke.number_of_windings["1"] is False + assert extension.choke.number_of_windings["2"] is True + assert extension.choke.number_of_windings["3"] is False + assert extension.choke.number_of_windings["4"] is False + + extension.root.destroy() + + +def test_update_parameter_config(mock_hfss_app): + """Test updating parameter configuration from entry widget.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Create a mock entry widget + mock_entry = MagicMock() + mock_entry.get.return_value = "25.5" + + extension.update_parameter_config("core", "Inner Radius", mock_entry) + + assert extension.choke.core["Inner Radius"] == 25.5 + + # Test with string value + mock_entry.get.return_value = "ferrite_new" + extension.update_parameter_config("core", "Material", mock_entry) + + assert extension.choke.core["Material"] == "ferrite_new" + + # Test with invalid value + mock_entry.get.return_value = "invalid_float" + extension.update_parameter_config("core", "Inner Radius", mock_entry) + + # Should not crash and value should be assigned as string since + # it's not a valid float + assert extension.choke.core["Inner Radius"] == "invalid_float" + + extension.root.destroy() + + +def test_update_radio_buttons(mock_hfss_app): + """Test updating radio button selections.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Setup mock selected_options + extension.selected_options = {} + for category in extension.boolean_categories: + extension.selected_options[category] = tkinter.StringVar() + + # Modify choke configuration + extension.choke.number_of_windings = { + "1": False, + "2": True, + "3": False, + "4": False, + } + extension.choke.layer = { + "Simple": False, + "Double": True, + "Triple": False, + } + + extension.update_radio_buttons() + + assert extension.selected_options["number_of_windings"].get() == "2" + assert extension.selected_options["layer"].get() == "Double" + + extension.root.destroy() + + +def test_update_entries(mock_hfss_app): + """Test updating entry widgets.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Setup mock entries + mock_entry1 = MagicMock() + mock_entry2 = MagicMock() + extension.entries_dict = { + ("Core", "Inner Radius"): mock_entry1, + ("Core", "Material"): mock_entry2, + } + + # Modify choke configuration + extension.choke.core["Inner Radius"] = 15.5 + extension.choke.core["Material"] = "new_material" + + extension.update_entries() + + mock_entry1.delete.assert_called_with(0, tkinter.END) + mock_entry1.insert.assert_called_with(0, "15.5") + mock_entry2.delete.assert_called_with(0, tkinter.END) + mock_entry2.insert.assert_called_with(0, "new_material") + + extension.root.destroy() + + +def test_callback_success(mock_hfss_app): + """Test successful callback execution.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Mock validation to return True + with patch.object(extension, "validate_configuration", return_value=True): + with patch.object(extension.root, "destroy") as mock_destroy: + extension.callback() + + assert extension.flag is True + assert isinstance(extension.data, ChokeDesignerExtensionData) + assert extension.data.choke is extension.choke + mock_destroy.assert_called_once() + + +def test_callback_validation_failure(mock_hfss_app): + """Test callback with validation failure.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Mock validation to return False + with patch.object(extension, "validate_configuration", return_value=False): + with patch.object(extension.root, "destroy") as mock_destroy: + extension.callback() + + assert extension.flag is True # Flag is set regardless + mock_destroy.assert_not_called() # Root should not be destroyed + + +def test_create_boolean_options(mock_hfss_app): + """Test creation of boolean option radio buttons.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Create a parent frame + parent = tkinter.Frame(extension.root) + + extension.create_boolean_options(parent) + + # Check that selected_options are created for boolean categories + assert len(extension.selected_options) == len(extension.boolean_categories) + + # Check that all categories have StringVar objects + for category in extension.boolean_categories: + if hasattr(extension.choke, category): + assert category in extension.selected_options + assert isinstance( + extension.selected_options[category], + tkinter.StringVar, + ) + + extension.root.destroy() + + +def test_create_parameter_inputs(mock_hfss_app): + """Test creation of parameter input widgets.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Create a parent frame + parent = tkinter.Frame(extension.root) + + extension.create_parameter_inputs(parent, "Core") + + # Check that entries are created for core parameters + core_fields = extension.choke.core.keys() + for field in core_fields: + assert ("Core", field) in extension.entries_dict + assert hasattr(extension.entries_dict[("Core", field)], "get") # Should be Entry widget + + extension.root.destroy() + + +def test_create_parameter_inputs_invalid_category(mock_hfss_app): + """Test creation of parameter inputs with invalid category.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Create a parent frame + parent = tkinter.Frame(extension.root) + + # Test with invalid category + initial_entries_count = len(extension.entries_dict) + extension.create_parameter_inputs(parent, "InvalidCategory") + + # Should not create any new entries + assert len(extension.entries_dict) == initial_entries_count + + extension.root.destroy() + + +def test_extension_ui_components(mock_hfss_app): + """Test that the extension UI components are properly created.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Check that the main UI components exist + assert extension.root.winfo_exists() + + # The extension should have its main content added + children = extension.root.winfo_children() + assert len(children) > 0 + + extension.root.destroy() + + +def test_choke_designer_extension_data_defaults(mock_hfss_app): + """Test ChokeDesignerExtensionData with default values.""" + data = ChokeDesignerExtensionData() + + assert data.choke is None + + +def test_choke_configuration_persistence(mock_hfss_app): + """Test that choke configuration changes persist.""" + extension = ChokeDesignerExtension(withdraw=True) + + # Modify configuration + original_radius = extension.choke.core["Inner Radius"] + extension.choke.core["Inner Radius"] = 99.9 + + assert extension.choke.core["Inner Radius"] == 99.9 + assert extension.choke.core["Inner Radius"] != original_radius + + extension.root.destroy() + + +def test_category_map_completeness(mock_hfss_app): + """Test that category map covers all expected categories.""" + extension = ChokeDesignerExtension(withdraw=True) + + expected_categories = [ + "core", + "outer_winding", + "mid_winding", + "inner_winding", + "settings", + ] + actual_categories = list(extension.category_map.values()) + + for category in expected_categories: + assert category in actual_categories + + extension.root.destroy() + + +def test_boolean_categories_completeness(mock_hfss_app): + """Test that boolean categories list is complete.""" + extension = ChokeDesignerExtension(withdraw=True) + + expected_boolean_categories = [ + "number_of_windings", + "layer", + "layer_type", + "similar_layer", + "mode", + "create_component", + "wire_section", + ] + + for category in expected_boolean_categories: + assert category in extension.boolean_categories + + extension.root.destroy()