diff --git a/doc/source/User_guide/pyaedt_extensions_doc/hfss/fresnel.rst b/doc/source/User_guide/pyaedt_extensions_doc/hfss/fresnel.rst new file mode 100644 index 00000000000..ad8062c9566 --- /dev/null +++ b/doc/source/User_guide/pyaedt_extensions_doc/hfss/fresnel.rst @@ -0,0 +1,20 @@ +Fresnel +======= + +With this extension, you can generate from a line a parametrize design to simulate a trajectory. + +You can access the extension from the icon created on the **Automation** tab using the Extension Manager. + +The following image shows the extension user interface: + +.. image:: ../../../_static/extensions/move_it_ui.png + :width: 800 + :alt: Move it UI + +You can also launch the extension user interface from the terminal. An example can be found here: + + +.. toctree:: + :maxdepth: 2 + + ../commandline \ No newline at end of file diff --git a/doc/source/User_guide/pyaedt_extensions_doc/hfss/index.rst b/doc/source/User_guide/pyaedt_extensions_doc/hfss/index.rst index bd83489d9d9..14590dd2121 100644 --- a/doc/source/User_guide/pyaedt_extensions_doc/hfss/index.rst +++ b/doc/source/User_guide/pyaedt_extensions_doc/hfss/index.rst @@ -37,3 +37,11 @@ HFSS extensions :margin: 2 2 0 0 Automated assembly workflow. + + + .. grid-item-card:: Fresnel + :link: fresnel + :link-type: doc + :margin: 2 2 0 0 + + Generate Fresnel coefficients from HFSS design. \ No newline at end of file diff --git a/src/ansys/aedt/core/application/analysis.py b/src/ansys/aedt/core/application/analysis.py index 84e6e2275fe..c735c3a2501 100644 --- a/src/ansys/aedt/core/application/analysis.py +++ b/src/ansys/aedt/core/application/analysis.py @@ -665,6 +665,9 @@ def design_excitations(self): """ exc_names = self.excitation_names[::] + # Filter modes + exc_names = list(set([item.split(":")[0] for item in exc_names])) + for el in self.boundaries: if el.name in exc_names: self._excitation_objects[el.name] = el @@ -673,7 +676,7 @@ def design_excitations(self): keys_to_remove = [ internal_excitation for internal_excitation in self._excitation_objects - if internal_excitation not in self.excitation_names + if internal_excitation not in exc_names ] for key in keys_to_remove: @@ -1540,9 +1543,9 @@ def create_output_variable(self, variable, expression, solution=None, context=No Parameters ---------- - variable : str, optional + variable : str Name of the variable. - expression : str, optional + expression : str Value for the variable. solution : str, optional Name of the solution in the format `"name : sweep_name"`. diff --git a/src/ansys/aedt/core/extensions/hfss/fresnel.py b/src/ansys/aedt/core/extensions/hfss/fresnel.py new file mode 100644 index 00000000000..278ebd173c4 --- /dev/null +++ b/src/ansys/aedt/core/extensions/hfss/fresnel.py @@ -0,0 +1,949 @@ +# -*- 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 tkinter +from tkinter import ttk + +from ansys.aedt.core.extensions.misc import ExtensionCommon +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_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.numbers_utils import Quantity + +PORT = get_port() +VERSION = get_aedt_version() +AEDT_PROCESS_ID = get_process_id() +IS_STUDENT = is_student() + +EXTENSION_TITLE = "Fresnel Coefficients" + +# Default width and height for the extension window +WIDTH = 650 +HEIGHT = 850 + +# Maximum dimensions for the extension window +MAX_WIDTH = 800 +MAX_HEIGHT = 950 + +# Minimum dimensions for the extension window +MIN_WIDTH = 600 +MIN_HEIGHT = 750 + + +class FresnelExtension(ExtensionHFSSCommon): + """Extension to generate Fresnel coefficients in AEDT.""" + + def __init__(self, withdraw: bool = False): + # Initialize the common extension class with the title and theme color + super().__init__( + EXTENSION_TITLE, + theme_color="light", + withdraw=withdraw, + add_custom_content=False, + toggle_row=4, + toggle_column=0, + ) + + # Attributes + self.fresnel_type = tkinter.StringVar(value="isotropic") + self.setups = self.aedt_application.design_setups + if not self.setups: + self.setups = {"No Setup": None} + + self.setup_names = list(self.setups.keys()) + + self.active_setup = None + self.sweep = None + self.active_setup_sweep = None + self.floquet_ports = None + self.active_parametric = None + self.start_frequency = None + self.stop_frequency = None + self.step_frequency = None + self.frequency_units = None + + # Layout + self.root.columnconfigure(0, weight=1) + + fresnel_frame = ttk.LabelFrame(self.root, text="Fresnel Coefficients Mode", style="PyAEDT.TLabelframe") + fresnel_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew") + + state = "normal" + if self.desktop.aedt_version_id < "2026.2": + # Anisotropic not available before 2026R2 + state = "disabled" + + # Anisotropic and isotropic workflows + isotropic_button = ttk.Radiobutton( + fresnel_frame, + text="Isotropic - scan over elevation only", + value="isotropic", + style="PyAEDT.TRadiobutton", + variable=self.fresnel_type, + command=self.on_fresnel_type_changed, + state=state, + ) + isotropic_button.grid(row=0, column=0, sticky="w") + self._widgets["anisotropic_button"] = isotropic_button + + anisotropic_button = ttk.Radiobutton( + fresnel_frame, + text="Anisotropic - scan over elevation and azimuth", + value="anisotropic", + style="PyAEDT.TRadiobutton", + variable=self.fresnel_type, + command=self.on_fresnel_type_changed, + state=state, + ) + anisotropic_button.grid(row=1, column=0, sticky="w") + self._widgets["isotropic_button"] = anisotropic_button + + # Extraction, advanced and automated workflows + tabs = ttk.Notebook(self.root, style="PyAEDT.TNotebook") + self._widgets["tabs"] = tabs + + self._widgets["tabs"].grid(row=1, column=0, padx=10, pady=10, sticky="nsew") + + self._widgets["extraction_tab"] = ttk.Frame(self._widgets["tabs"], style="PyAEDT.TFrame") + # self._widgets["auto_tab"] = ttk.Frame(self._widgets["tabs"], style="PyAEDT.TFrame") + self._widgets["advanced_tab"] = ttk.Frame(self._widgets["tabs"], style="PyAEDT.TFrame") + self._widgets["settings_tab"] = ttk.Frame(self._widgets["tabs"], style="PyAEDT.TFrame") + + self._widgets["tabs"].add(self._widgets["extraction_tab"], text="Extraction") + + # self._widgets["tabs"].add(self._widgets["auto_tab"], text="Automated") + # Disable Automated workflow until it is not implemented + # self._widgets["tabs"].tab(self._widgets["auto_tab"], state="disabled") + + self._widgets["tabs"].add(self._widgets["advanced_tab"], text="Advanced") + self._widgets["tabs"].add(self._widgets["settings_tab"], text="Simulation Settings") + + # Select the "Advanced Workflow" tab by default + self._widgets["tabs"].select(self._widgets["extraction_tab"]) + + # Angle resolution + self._widgets["elevation_resolution"] = tkinter.DoubleVar(value=7.5) + self._widgets["azimuth_resolution"] = tkinter.DoubleVar(value=10.0) + self._widgets["theta_scan_max"] = tkinter.DoubleVar(value=15.0) + + self.elevation_resolution_slider_values = [10.0, 7.5, 5.0] + self.azimuth_resolution_slider_values = [15.0, 10.0, 7.5] + + self.elevation_resolution_values = [ + 1.0, + 1.25, + 1.5, + 2.0, + 2.5, + 3.75, + 5.0, + 6.0, + 7.5, + 9.0, + 10.0, + 11.25, + 15.0, + 18.0, + 22.5, + 30.0, + ] + self.azimuth_resolution_values = self.elevation_resolution_values + + # self.build_automated_tab(auto_tab) + self.build_advanced_tab() + self.build_extraction_tab() + self.build_settings_tab() + + self.root.minsize(MIN_WIDTH, MIN_HEIGHT) + self.root.maxsize(MAX_WIDTH, MAX_HEIGHT) + self.root.geometry(f"{WIDTH}x{HEIGHT}") + + def on_fresnel_type_changed(self): + selected = self.fresnel_type.get() + # selected_tab = self._widgets["tabs"].index(self._widgets["tabs"].select()) + if selected == "isotropic": + self._widgets["azimuth_slider"].grid_remove() + self._widgets["azimuth_spin"].grid_remove() + self._widgets["azimuth_label"].grid_remove() + elif selected == "anisotropic": + self._widgets["azimuth_slider"].grid() + self._widgets["azimuth_spin"].grid() + self._widgets["azimuth_label"].grid() + + def build_advanced_tab(self): + # Setup + label = ttk.Label(self._widgets["advanced_tab"], text="Simulation setup", style="PyAEDT.TLabel") + label.grid(row=0, column=0, padx=15, pady=10) + + self._widgets["setup_combo"] = ttk.Combobox( + self._widgets["advanced_tab"], width=30, style="PyAEDT.TCombobox", name="simulation_setup", state="readonly" + ) + self._widgets["setup_combo"].grid(row=0, column=1, padx=15, pady=10) + + self._widgets["setup_combo"]["values"] = self.setup_names + self._widgets["setup_combo"].current(0) + self.active_setup = self.setups[self.setup_names[0]] + + # Sweep + self._widgets["frequency_sweep_frame"] = ttk.LabelFrame( + self._widgets["advanced_tab"], text="Frequency sweep", padding=10, style="PyAEDT.TLabelframe" + ) + self._widgets["frequency_sweep_frame"].grid(row=1, column=0, padx=10, pady=10, columnspan=2) + + ttk.Label(self._widgets["frequency_sweep_frame"], text="Start", style="PyAEDT.TLabel").grid( + row=0, column=1, padx=10 + ) + ttk.Label(self._widgets["frequency_sweep_frame"], text="Stop", style="PyAEDT.TLabel").grid( + row=0, column=2, padx=10 + ) + ttk.Label(self._widgets["frequency_sweep_frame"], text="Step", style="PyAEDT.TLabel").grid( + row=0, column=3, padx=10 + ) + ttk.Label(self._widgets["frequency_sweep_frame"], text="Frequency Units", style="PyAEDT.TLabel").grid( + row=0, column=4, padx=10 + ) + + initial_freq = "1.0" + if hasattr(self.active_setup, "properties") and "Solution Freq" in self.active_setup.properties: + freq_mesh = Quantity(self.active_setup.properties["Solution Freq"]) + initial_freq = str(freq_mesh.value) + + self._widgets["start_frequency"] = tkinter.Text(self._widgets["frequency_sweep_frame"], width=10, height=1) + self._widgets["start_frequency"].insert(tkinter.END, initial_freq) + self._widgets["start_frequency"].grid(row=1, column=1, padx=10) + self._widgets["start_frequency"].configure( + background=self.theme.light["label_bg"], foreground=self.theme.light["text"], font=self.theme.default_font + ) + + self._widgets["stop_frequency"] = tkinter.Text(self._widgets["frequency_sweep_frame"], width=10, height=1) + self._widgets["stop_frequency"].insert(tkinter.END, initial_freq) + self._widgets["stop_frequency"].grid(row=1, column=2, padx=10) + self._widgets["stop_frequency"].configure( + background=self.theme.light["label_bg"], foreground=self.theme.light["text"], font=self.theme.default_font + ) + + self._widgets["step_frequency"] = tkinter.Text(self._widgets["frequency_sweep_frame"], width=10, height=1) + self._widgets["step_frequency"].insert(tkinter.END, "0.1") + self._widgets["step_frequency"].grid(row=1, column=3, padx=10) + self._widgets["step_frequency"].configure( + background=self.theme.light["label_bg"], foreground=self.theme.light["text"], font=self.theme.default_font + ) + + self._widgets["frequency_units_combo"] = ttk.Combobox( + self._widgets["frequency_sweep_frame"], width=15, style="PyAEDT.TCombobox", state="readonly" + ) + self._widgets["frequency_units_combo"].grid(row=1, column=4, padx=10) + self._widgets["frequency_units_combo"]["values"] = ["GHz", "MHz", "kHz", "Hz"] + self._widgets["frequency_units_combo"].current(0) + + # Angular resolution + self._widgets["angular_resolution_frame"] = ttk.LabelFrame( + self._widgets["advanced_tab"], text="Angular resolution", padding=10, style="PyAEDT.TLabelframe" + ) + self._widgets["angular_resolution_frame"].grid(row=2, column=0, padx=15, pady=10, columnspan=2) + + # Slider positions + for i, val in enumerate(["Coarse", "Regular", "Fine"]): + ttk.Label(self._widgets["angular_resolution_frame"], text=val, style="PyAEDT.TLabel").grid( + row=0, column=1 + i, padx=15 + ) + + # Elevation slider + ttk.Label(self._widgets["angular_resolution_frame"], text="Theta:", style="PyAEDT.TLabel").grid( + row=1, column=0, padx=15, pady=10 + ) + + self._widgets["elevation_slider"] = ttk.Scale( + self._widgets["angular_resolution_frame"], + from_=0, + to=2, + orient="horizontal", + command=self.elevation_slider_changed, + length=200, + ) + self._widgets["elevation_slider"].grid(row=1, column=1, columnspan=3) + + self._widgets["elevation_spin"] = ttk.Spinbox( + self._widgets["angular_resolution_frame"], + values=[str(v) for v in self.elevation_resolution_values], + textvariable=self._widgets["elevation_resolution"], + width=6, + command=self.elevation_spin_changed, + state="readonly", + font=self.theme.default_font, + style="PyAEDT.TSpinbox", + ) + self._widgets["elevation_spin"].grid(row=1, column=4, padx=15) + self._widgets["elevation_slider"].set(1) + + # Azimuth slider + self._widgets["azimuth_label"] = ttk.Label( + self._widgets["angular_resolution_frame"], text="Phi:", style="PyAEDT.TLabel" + ) + self._widgets["azimuth_label"].grid(row=2, column=0, padx=15, pady=10) + + self._widgets["azimuth_slider"] = ttk.Scale( + self._widgets["angular_resolution_frame"], + from_=0, + to=2, + orient="horizontal", + command=self.azimuth_slider_changed, + length=200, + ) + self._widgets["azimuth_slider"].grid(row=2, column=1, columnspan=3) + + self._widgets["azimuth_spin"] = ttk.Spinbox( + self._widgets["angular_resolution_frame"], + values=[str(v) for v in self.azimuth_resolution_values], + textvariable=self._widgets["azimuth_resolution"], + width=6, + state="readonly", + font=self.theme.default_font, + style="PyAEDT.TSpinbox", + ) + self._widgets["azimuth_spin"].grid(row=2, column=4, padx=15) + self._widgets["azimuth_slider"].set(1) + + # Disabled by default in Isotropic mode + self._widgets["azimuth_slider"].grid_remove() + self._widgets["azimuth_spin"].grid_remove() + self._widgets["azimuth_label"].grid_remove() + + # Separator + separator = ttk.Separator(self._widgets["angular_resolution_frame"], orient="horizontal") + separator.grid(row=3, column=0, columnspan=5, sticky="ew", padx=15, pady=10) + + # Elevation max + ttk.Label(self._widgets["angular_resolution_frame"], text="Theta MAX:", style="PyAEDT.TLabel").grid( + row=4, column=0, padx=15, pady=10 + ) + + self._widgets["theta_scan_max_slider"] = ttk.Scale( + self._widgets["angular_resolution_frame"], + from_=0, + to=90, + orient="horizontal", + variable=self._widgets["theta_scan_max"], + command=self.snap_theta_scan_max_slider, + length=200, + ) + self._widgets["theta_scan_max_slider"].grid(row=4, column=1, columnspan=3) + + self._widgets["theta_scan_max_spin"] = ttk.Spinbox( + self._widgets["angular_resolution_frame"], + from_=0, + to=90, + increment=1, + textvariable=self._widgets["theta_scan_max"], + width=6, + command=self.snap_theta_scan_max_spin, + font=self.theme.default_font, + style="PyAEDT.TSpinbox", + ) + self._widgets["theta_scan_max_spin"].grid(row=4, column=4) + + # Apply and Validate button + self._widgets["apply_validate_button"] = ttk.Button( + self._widgets["advanced_tab"], + text="Apply and Validate", + width=40, + command=lambda: self.apply_validate(), + style="PyAEDT.TButton", + ) # nosec + self._widgets["apply_validate_button"].grid(row=4, column=0, padx=15, pady=10, columnspan=2) + + # Validation menu + self._widgets["validation_frame"] = ttk.LabelFrame( + self._widgets["advanced_tab"], text="Validation", padding=10, style="PyAEDT.TLabelframe" + ) + self._widgets["validation_frame"].grid(row=5, column=0, padx=10, pady=10, columnspan=2) + + ttk.Label(self._widgets["validation_frame"], text="Floquet ports: ", style="PyAEDT.TLabel").grid( + row=0, column=1, padx=10 + ) + self._widgets["floquet_ports_label"] = ttk.Label(self._widgets["validation_frame"], style="PyAEDT.TLabel") + self._widgets["floquet_ports_label"].grid(row=0, column=2, padx=10) + self._widgets["floquet_ports_label"]["text"] = "N/A" + + ttk.Label(self._widgets["validation_frame"], text="Frequency points: ", style="PyAEDT.TLabel").grid( + row=1, column=1, padx=10 + ) + self._widgets["frequency_points_label"] = ttk.Label(self._widgets["validation_frame"], style="PyAEDT.TLabel") + self._widgets["frequency_points_label"].grid(row=1, column=2, padx=10) + self._widgets["frequency_points_label"]["text"] = "N/A" + + ttk.Label(self._widgets["validation_frame"], text="Spatial directions: ", style="PyAEDT.TLabel").grid( + row=2, column=1, padx=10 + ) + self._widgets["spatial_points_label"] = ttk.Label(self._widgets["validation_frame"], style="PyAEDT.TLabel") + self._widgets["spatial_points_label"].grid(row=2, column=2, padx=10) + self._widgets["spatial_points_label"]["text"] = "N/A" + + ttk.Label(self._widgets["validation_frame"], text="Design validation: ", style="PyAEDT.TLabel").grid( + row=3, column=1, padx=10 + ) + self._widgets["design_validation_label"] = ttk.Label(self._widgets["validation_frame"], style="PyAEDT.TLabel") + self._widgets["design_validation_label"].grid(row=3, column=2, padx=10) + self._widgets["design_validation_label"]["text"] = "N/A" + + # Start button + self._widgets["start_button"] = ttk.Button( + self._widgets["advanced_tab"], + text="Start", + width=40, + command=lambda: self.start_extraction(), + style="PyAEDT.TButton", + ) # nosec + self._widgets["start_button"].grid(row=6, column=0, padx=15, pady=10, columnspan=2) + self._widgets["start_button"].grid_remove() + + def build_extraction_tab(self): + # Setup + label = ttk.Label(self._widgets["extraction_tab"], text="Parametric setup", style="PyAEDT.TLabel") + label.grid(row=0, column=0, padx=15, pady=10) + + self._widgets["parametric_combo"] = ttk.Combobox( + self._widgets["extraction_tab"], + width=30, + style="PyAEDT.TCombobox", + name="parametric_setup", + state="readonly", + ) + self._widgets["parametric_combo"].grid(row=0, column=1, padx=15, pady=10) + + parametric_names = list(self.aedt_application.parametrics.design_setups.keys()) + + if parametric_names: + self._widgets["parametric_combo"]["values"] = parametric_names + self.active_parametric = self.aedt_application.parametrics.design_setups[parametric_names[0]] + else: + self._widgets["parametric_combo"]["values"] = "No parametric setup" + self._widgets["parametric_combo"].current(0) + + # Validate button + self._widgets["validate_button"] = ttk.Button( + self._widgets["extraction_tab"], + text="Validate", + width=40, + command=lambda: self.validate(), + style="PyAEDT.TButton", + ) # nosec + self._widgets["validate_button"].grid(row=1, column=0, padx=15, pady=10, columnspan=2) + + # Validation menu + self._widgets["validation_frame_extraction"] = ttk.LabelFrame( + self._widgets["extraction_tab"], text="Validation", padding=10, style="PyAEDT.TLabelframe" + ) + self._widgets["validation_frame_extraction"].grid(row=2, column=0, padx=10, pady=10, columnspan=2) + + ttk.Label(self._widgets["validation_frame_extraction"], text="Floquet ports: ", style="PyAEDT.TLabel").grid( + row=0, column=1, padx=10 + ) + self._widgets["floquet_ports_label_extraction"] = ttk.Label( + self._widgets["validation_frame_extraction"], style="PyAEDT.TLabel" + ) + self._widgets["floquet_ports_label_extraction"].grid(row=0, column=2, padx=10) + self._widgets["floquet_ports_label_extraction"]["text"] = "N/A" + + ttk.Label( + self._widgets["validation_frame_extraction"], text="Spatial directions: ", style="PyAEDT.TLabel" + ).grid(row=2, column=1, padx=10) + self._widgets["spatial_points_label_extraction"] = ttk.Label( + self._widgets["validation_frame_extraction"], style="PyAEDT.TLabel" + ) + self._widgets["spatial_points_label_extraction"].grid(row=2, column=2, padx=10) + self._widgets["spatial_points_label_extraction"]["text"] = "N/A" + + ttk.Label(self._widgets["validation_frame_extraction"], text="Design validation: ", style="PyAEDT.TLabel").grid( + row=3, column=1, padx=10 + ) + self._widgets["design_validation_label_extraction"] = ttk.Label( + self._widgets["validation_frame_extraction"], style="PyAEDT.TLabel" + ) + self._widgets["design_validation_label_extraction"].grid(row=3, column=2, padx=10) + self._widgets["design_validation_label_extraction"]["text"] = "N/A" + + # Start button + self._widgets["start_button_extraction"] = ttk.Button( + self._widgets["extraction_tab"], + text="Start", + width=40, + command=lambda: self.get_coefficients(), + style="PyAEDT.TButton", + ) # nosec + self._widgets["start_button_extraction"].grid(row=4, column=0, padx=15, pady=10, columnspan=2) + self._widgets["start_button_extraction"].grid_remove() + + def build_settings_tab(self): + # Simulation menu + self._widgets["hpc_frame"] = ttk.LabelFrame( + self._widgets["settings_tab"], text="HPC options", padding=10, style="PyAEDT.TLabelframe" + ) + self._widgets["hpc_frame"].grid(row=0, column=0, padx=10, pady=10, columnspan=2) + + ttk.Label(self._widgets["hpc_frame"], text="Cores: ", style="PyAEDT.TLabel").grid(row=0, column=1, padx=10) + self._widgets["core_number"] = tkinter.Text(self._widgets["hpc_frame"], width=20, height=1) + self._widgets["core_number"].insert(tkinter.END, "4") + self._widgets["core_number"].grid(row=0, column=2, padx=10) + self._widgets["core_number"].configure( + background=self.theme.light["label_bg"], foreground=self.theme.light["text"], font=self.theme.default_font + ) + + ttk.Label(self._widgets["hpc_frame"], text="Tasks: ", style="PyAEDT.TLabel").grid(row=1, column=1, padx=10) + self._widgets["tasks_number"] = tkinter.Text(self._widgets["hpc_frame"], width=20, height=1) + self._widgets["tasks_number"].insert(tkinter.END, "1") + self._widgets["tasks_number"].grid(row=1, column=2, padx=10) + self._widgets["tasks_number"].configure( + background=self.theme.light["label_bg"], foreground=self.theme.light["text"], font=self.theme.default_font + ) + + # Simulation menu + self._widgets["optimetrics_frame"] = ttk.LabelFrame( + self._widgets["settings_tab"], text="Optimetrics options", padding=10, style="PyAEDT.TLabelframe" + ) + self._widgets["optimetrics_frame"].grid(row=1, column=0, padx=10, pady=10, columnspan=2, sticky="ew") + + self._widgets["keep_mesh"] = tkinter.BooleanVar() + self._widgets["keep_mesh_checkbox"] = ttk.Checkbutton( + self._widgets["optimetrics_frame"], + text="Same mesh for all variations", + variable=self._widgets["keep_mesh"], + style="PyAEDT.TCheckbutton", + ) + self._widgets["keep_mesh_checkbox"].grid(row=0, column=1, columnspan=2, padx=10, sticky="w") + self._widgets["keep_mesh"].set(True) + + def elevation_slider_changed(self, pos): + index = int(float(pos)) + new_val = self.elevation_resolution_slider_values[index] + self._widgets["elevation_resolution"].set(new_val) + self._widgets["elevation_spin"].set(new_val) + self.update_theta_scan_max_constraints() + + def elevation_spin_changed(self): + self.update_theta_scan_max_constraints() + + def azimuth_slider_changed(self, pos): + index = int(float(pos)) + new_val = self.azimuth_resolution_slider_values[index] + self._widgets["azimuth_resolution"].set(new_val) + self._widgets["azimuth_spin"].set(new_val) + + def update_theta_scan_max_constraints(self): + theta_val = self._widgets["elevation_resolution"].get() + if theta_val <= 0 or theta_val > 90: + return + + max_step = int(90 / theta_val) + last_value = round(max_step * theta_val, 2) + + if last_value > 90: # pragma: no cover + last_value = 90 - theta_val + elif last_value < 90 and abs(90 - last_value) < 1e-6: + last_value = 90.0 + + if "theta_scan_max_slider" in self._widgets: + self._widgets["theta_scan_max_slider"].config( + from_=theta_val, + to=last_value, + ) + if "theta_scan_max_spin" in self._widgets: + self._widgets["theta_scan_max_spin"].config(from_=theta_val, to=last_value, increment=theta_val) + + current_val = self._widgets["theta_scan_max"].get() + snapped = round(current_val / theta_val) * theta_val + if snapped > last_value: + snapped = last_value + self._widgets["theta_scan_max"].set(round(snapped, 2)) + + def snap_theta_scan_max_slider(self, val): + theta_step = float(self._widgets["elevation_resolution"].get()) + val = float(val) + snapped = round(val / theta_step) * theta_step + if snapped > 90: + snapped = 90 - theta_step + self._widgets["theta_scan_max_spin"].set(round(snapped, 2)) + + def snap_theta_scan_max_spin(self): + self.snap_theta_scan_max_slider(self._widgets["theta_scan_max_slider"].get()) + + def apply_validate(self): + # Init + self._widgets["frequency_points_label"].config(text="N/A") + self._widgets["floquet_ports_label"].config(text="N/A") + self._widgets["design_validation_label"].config(text="N/A") + self._widgets["spatial_points_label"].config(text="N/A") + self._widgets["start_button"].grid_remove() + + simulation_setup = self._widgets["setup_combo"].get() + + if simulation_setup == "No Setup": + self.aedt_application.logger.error("No setup selected.") + return + + # Create sweep + self.active_setup = self.aedt_application.design_setups[simulation_setup] + + self.start_frequency = float(self._widgets["start_frequency"].get("1.0", tkinter.END).strip()) + self.stop_frequency = float(self._widgets["stop_frequency"].get("1.0", tkinter.END).strip()) + self.step_frequency = float(self._widgets["step_frequency"].get("1.0", tkinter.END).strip()) + self.frequency_units = self._widgets["frequency_units_combo"].get() + + if self.start_frequency > self.stop_frequency: + self.aedt_application.logger.error("Start frequency must be less than stop frequency.") + self._widgets["frequency_points_label"].config(text="❌") + return + + for _, available_sweep in self.active_setup.children.items(): + available_sweep.properties["Enabled"] = False + + self.sweep = self.active_setup.add_sweep(type="Interpolating") + + self.sweep.props["Type"] = "Interpolating" + self.sweep.props["SaveFields"] = False + + if self.start_frequency == self.stop_frequency: + self.sweep.props["Type"] = "Discrete" + self.sweep.props["RangeType"] = "SinglePoints" + else: + self.sweep.props["RangeType"] = "LinearStep" + + self.sweep.props["RangeStart"] = f"{self.start_frequency}{self.frequency_units}" + self.sweep.props["RangeEnd"] = f"{self.stop_frequency}{self.frequency_units}" + self.sweep.props["RangeStep"] = f"{self.step_frequency}{self.frequency_units}" + self.sweep.update() + + # Check number of ports and each port should have 2 modes + self.floquet_ports = self.aedt_application.get_fresnel_floquet_ports() + if self.floquet_ports is None: + self._widgets["floquet_ports_label"]["text"] = "❌" + return + self._widgets["floquet_ports_label"]["text"] = f"{len(self.floquet_ports)} Floquet port defined" + " ✅" + + # Show frequency points + + frequency_points = int((self.stop_frequency - self.start_frequency) / self.step_frequency) + 1 + self._widgets["frequency_points_label"]["text"] = str(frequency_points) + " ✅" + + # Show spatial directions + + theta_resolution = float(self._widgets["elevation_resolution"].get()) + phi_resolution = float(self._widgets["azimuth_resolution"].get()) + phi_max = 360.0 - phi_resolution + is_isotropic = self.fresnel_type.get() == "isotropic" + if is_isotropic: + phi_resolution = 1.0 + phi_max = 0 + theta_max = float(self._widgets["theta_scan_max"].get()) + + theta_steps = int(theta_max / theta_resolution) + 1 + phi_steps = int(phi_max / phi_resolution) + 1 + + total_combinations = theta_steps * phi_steps + self._widgets["spatial_points_label"]["text"] = str(total_combinations) + " ✅" + + # Check validations + + validation = self.aedt_application.validate_simple() + if validation == 1: + self._widgets["design_validation_label"]["text"] = "Passed ✅" + else: + self._widgets["design_validation_label"].config(text="Failed ❌") + return + + # Check if lattice pair + bounds = self.aedt_application.boundaries_by_type + if "Lattice Pair" not in bounds: + self.aedt_application.logger.error("No lattice pair found.") + self._widgets["design_validation_label"].config(text="Failed ❌") + return + + # Assign variable to lattice pair + self.aedt_application["scan_P"] = "0deg" + self.aedt_application["scan_T"] = "0deg" + + for lattice_pair in bounds["Lattice Pair"]: + lattice_pair.properties["Theta"] = "scan_T" + lattice_pair.properties["Phi"] = "scan_P" + + # Create optimetrics + for available_sweep in self.aedt_application.parametrics.setups: + available_sweep.props["IsEnabled"] = False + available_sweep.update() + + self.active_parametric = self.aedt_application.parametrics.add( + "scan_T", 0.0, theta_max, theta_resolution, variation_type="LinearStep", solution=self.active_setup.name + ) + + if not is_isotropic: + self.active_parametric.add_variation("scan_P", 0.0, phi_max, phi_resolution, variation_type="LinearStep") + + # Save mesh and equivalent meshes + self.active_parametric.props["ProdOptiSetupDataV2"]["CopyMesh"] = self._widgets["keep_mesh"].get() + self.active_parametric.props["ProdOptiSetupDataV2"]["SaveFields"] = False + + # Create output variables + self.active_setup_sweep = self.active_setup.name + " : " + self.sweep.name + self.aedt_application.create_fresnel_variables(self.active_setup_sweep) + + self.aedt_application.save_project() + + self._widgets["start_button"].grid() + + return True + + def validate(self): + # Init + self._widgets["floquet_ports_label_extraction"].config(text="N/A") + self._widgets["spatial_points_label_extraction"].config(text="N/A") + self._widgets["design_validation_label_extraction"].config(text="N/A") + self._widgets["start_button_extraction"].grid_remove() + + simulation_setup = self._widgets["parametric_combo"].get() + + if simulation_setup == "No parametric setup": + self.aedt_application.logger.error("No parametric setup selected.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + + # Create sweep + self.active_parametric = self.aedt_application.parametrics.design_setups[simulation_setup] + + # Parametric setup must have only one Simulation setup + + setups = self.active_parametric.props["Sim. Setups"] + + if len(setups) != 1: + self.aedt_application.logger.error("Parametric setup must have only one Simulation setup.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + + self.active_setup = self.setups[setups[0]] + + # Setup has only one frequency sweep + + sweeps = self.active_setup.sweeps + + if len(sweeps) != 1: + self.aedt_application.logger.error(f"Setup {self.active_setup.name} must have only one sweep setup.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + + sweep = sweeps[0] + + self.active_setup_sweep = self.active_setup.name + " : " + sweep.name + + # Frequency sweep has linearly frequency samples + sweep_type = sweep.props.get("RangeType", None) + + if sweep_type not in ["LinearStep", "LinearCount", "SinglePoints"]: + self.aedt_application.logger.error(f"{sweep.name} does not have linearly frequency samples.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + + # We can not get the frequency points with the AEDT API + + # Floquet and modes + + # Check number of ports and each port should have 2 modes + self.floquet_ports = self.aedt_application.get_fresnel_floquet_ports() + if self.floquet_ports is None: + self._widgets["floquet_ports_label_extraction"]["text"] = "❌" + return + self._widgets["floquet_ports_label_extraction"]["text"] = ( + f"{len(self.floquet_ports)} Floquet port defined" + " ✅" + ) + + # Parametric setup is driven by the variables defining the scan direction + + bounds = self.aedt_application.boundaries_by_type + if "Lattice Pair" not in bounds: + self.aedt_application.logger.error("No lattice pair found.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + + lattice_pair = bounds["Lattice Pair"] + + theta_scan_variable = lattice_pair[0].properties["Theta"] + phi_scan_variable = lattice_pair[0].properties["Phi"] + is_isotropic = self.fresnel_type.get() == "isotropic" + + report_quantities = self.aedt_application.post.available_report_quantities() + + variations = self.aedt_application.available_variations.all + variations["Freq"] = "All" + + data = self.aedt_application.post.get_solution_data_per_variation( + "Modal Solution Data", self.active_setup_sweep, ["Domain:=", "Sweep"], variations, report_quantities[0] + ) + + parametric_data = self.extract_parametric_fresnel( + data.variations, theta_key=theta_scan_variable, phi_key=phi_scan_variable + ) + + if is_isotropic: + if parametric_data["has_phi"]: + if parametric_data["phi"][0] != 0.0: + self.aedt_application.logger.error("Phi sweep must contain 0.0deg.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + phi_0 = parametric_data["phi"][0] + theta_resolution = parametric_data["theta_resolution_by_phi"][phi_0] + phi_resolution = 1.0 + phi_max = 0 + theta_max = parametric_data["theta_by_phi"][phi_0][-1] + else: + theta_resolution = parametric_data["theta_resolution"] + phi_resolution = 1.0 + phi_max = 0 + theta_max = parametric_data["theta"][-1] + else: + if not parametric_data["has_phi"]: + self.aedt_application.logger.error("Scan phi is not defined.") + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + phi_0 = parametric_data["phi"][0] + theta_resolution = parametric_data["theta_resolution_by_phi"][phi_0] + theta_max = parametric_data["theta_by_phi"][phi_0][-1] + phi_resolution = parametric_data["phi"][1] - parametric_data["phi"][0] + phi_max = 360.0 - phi_resolution + + # Show spatial directions + + theta_steps = int(theta_max / theta_resolution) + 1 + phi_steps = int(phi_max / phi_resolution) + 1 + + total_combinations = theta_steps * phi_steps + self._widgets["spatial_points_label_extraction"]["text"] = str(total_combinations) + " ✅" + + # Check validations + + validation = self.aedt_application.validate_simple() + if validation == 1: + self._widgets["design_validation_label_extraction"]["text"] = "Passed ✅" + else: + self._widgets["design_validation_label_extraction"].config(text="Failed ❌") + return + + self._widgets["start_button_extraction"].grid() + + return True + + def start_extraction(self): + cores = int(self._widgets["core_number"].get("1.0", tkinter.END).strip()) + tasks = int(self._widgets["tasks_number"].get("1.0", tkinter.END).strip()) + active_parametric = self.active_parametric.name + + # Solve + self.aedt_application.analyze_setup(cores=cores, num_variations_to_distribute=tasks, name=active_parametric) + + self.aedt_application.save_project() + + self.get_coefficients() + + def get_coefficients(self): + self.aedt_application.get_fresnel_coefficients( + setup_sweep=self.active_setup_sweep, + theta_name="scan_T", + phi_name="scan_P", + ) + + self.release_desktop() + + self.root.destroy() + + @staticmethod + def validate_even_and_divides_90(values): + """Validates if list is equally spaced.""" + if len(values) < 2: + return True, None + step = values[1] - values[0] + + evenly = all((values[i] - values[i - 1]) == step for i in range(1, len(values))) + + divides_90 = (90.0 / step).is_integer() + + return (evenly and divides_90), step + + def extract_parametric_fresnel(self, rows, theta_key="scan_T", phi_key="scan_P"): + if not rows: + return {"has_phi": False, "theta": [], "phi": [], "theta_by_phi": {}} + + # Is phi key defined? + has_phi = any(phi_key in r for r in rows) + + if not has_phi: + # Only theta + thetas = [r[theta_key].value for r in rows if theta_key in r] + thetas = sorted(set(thetas)) + valid, step = self.validate_even_and_divides_90(thetas) + return { + "has_phi": False, + "theta": thetas, + "theta_resolution": step, + "theta_valid": valid, + "phi": [], + "theta_by_phi": {}, + } + + # phi groups + theta_by_phi = {} + for r in rows: + if theta_key not in r or phi_key not in r: + continue + phi = r[phi_key].value + theta = r[theta_key].value + theta_by_phi.setdefault(phi, []).append(theta) + + # Order list + for phi, lst in theta_by_phi.items(): + theta_by_phi[phi] = sorted(set(lst)) + + # Sweep phi + phi_values = sorted(theta_by_phi.keys()) + + # Phi validations + theta_resolution_by_phi = {} + theta_valid_by_phi = {} + for phi, lst in theta_by_phi.items(): + valid, step = self.validate_even_and_divides_90(lst) + theta_resolution_by_phi[phi] = step + theta_valid_by_phi[phi] = valid + + return { + "has_phi": True, + "phi": phi_values, + "theta_by_phi": theta_by_phi, + "theta_resolution_by_phi": theta_resolution_by_phi, + "theta_valid_by_phi": theta_valid_by_phi, + } + + +if __name__ == "__main__": # pragma: no cover + # Open UI + extension: ExtensionCommon = FresnelExtension(withdraw=False) + + tkinter.mainloop() diff --git a/src/ansys/aedt/core/extensions/hfss/images/large/fresnel.png b/src/ansys/aedt/core/extensions/hfss/images/large/fresnel.png new file mode 100644 index 00000000000..bf59c3ba333 Binary files /dev/null and b/src/ansys/aedt/core/extensions/hfss/images/large/fresnel.png differ diff --git a/src/ansys/aedt/core/extensions/hfss/toolkits_catalog.toml b/src/ansys/aedt/core/extensions/hfss/toolkits_catalog.toml index dea137b1449..d728a5f5954 100644 --- a/src/ansys/aedt/core/extensions/hfss/toolkits_catalog.toml +++ b/src/ansys/aedt/core/extensions/hfss/toolkits_catalog.toml @@ -36,3 +36,10 @@ script = "mcad_assembly.py" icon = "images/large/mcad_assembly.png" template = "run_pyaedt_toolkit_script" url = "https://aedt.docs.pyansys.com/version/stable/User_guide/pyaedt_extensions_doc/hfss/mcad_assembly.html" + + +[Fresnel] +name = "Fresnel" +script = "fresnel.py" +icon = "images/large/fresnel.png" +template = "run_pyaedt_toolkit_script" diff --git a/src/ansys/aedt/core/extensions/misc.py b/src/ansys/aedt/core/extensions/misc.py index a314b51699a..0ae1191c380 100644 --- a/src/ansys/aedt/core/extensions/misc.py +++ b/src/ansys/aedt/core/extensions/misc.py @@ -860,6 +860,15 @@ def _apply_theme(self, style, colors): foreground=[("active", "black"), ("!active", "black")], ) + # Apply the colors and font to the style for SpinBox + style.configure( + "PyAEDT.TSpinbox", + fieldbackground=colors["combobox_bg"], + background=colors["combobox_arrow_bg"], + foreground=colors["text"], + font=self.default_font, + ) + def __string_to_bool(v): # pragma: no cover """Change string to bool.""" diff --git a/src/ansys/aedt/core/hfss.py b/src/ansys/aedt/core/hfss.py index 998058efd98..05006fd0609 100644 --- a/src/ansys/aedt/core/hfss.py +++ b/src/ansys/aedt/core/hfss.py @@ -24,9 +24,12 @@ """This module contains the ``Hfss`` class.""" +from collections import defaultdict import math from pathlib import Path +import re import tempfile +from typing import List from typing import Optional from typing import Union import warnings @@ -5687,9 +5690,9 @@ def insert_near_field_box( props[defs[0]] = self.value_with_units(u_length, units) props[defs[1]] = self.value_with_units(v_length, units) props[defs[2]] = self.value_with_units(w_length, units) - props[defs[3]] = self.value_with_units(u_samples, units) - props[defs[4]] = self.value_with_units(v_samples, units) - props[defs[5]] = self.value_with_units(w_samples, units) + props[defs[3]] = u_samples + props[defs[4]] = v_samples + props[defs[5]] = w_samples if custom_coordinate_system: props["CoordSystem"] = custom_coordinate_system @@ -7852,3 +7855,520 @@ def delete_table(self, name): # UI is not updated, and it needs to save the project self.save_project() return True + + @pyaedt_function_handler() + def create_fresnel_variables(self, setup_sweep: str) -> None: + """ + Create (or overwrite) the output variables in HFSS needed to compute Fresnel reflection/transmission + coefficients between Floquet ports. + + Parameters + ---------- + setup_sweep : str + Name of the setup and sweep. + """ + floquet_ports = self.get_fresnel_floquet_ports() + is_reflection = len(floquet_ports) == 1 + + def _create_var(variable: str, expression: str) -> None: + self.create_output_variable(variable=variable, expression=expression, solution=setup_sweep) + + # Always create the base reflection variables (exist for both isotropic & anisotropic) + _create_var("r_te", f"S({floquet_ports[0]}:1,{floquet_ports[0]}:1)") + _create_var("r_tm", f"-S({floquet_ports[0]}:2,{floquet_ports[0]}:2)") + + # Cross-pol reflection (safe to create always; unused for isotropic cases) + _create_var("r_tm_te", f"-S({floquet_ports[0]}:2,{floquet_ports[0]}:1)") + _create_var("r_te_tm", f"S({floquet_ports[0]}:1,{floquet_ports[0]}:2)") + + # Transmission variables only if there are two Floquet ports + if not is_reflection: + top, bot = floquet_ports[0], floquet_ports[1] + # Co-pol transmission + _create_var("t_te", f"S({bot}:1,{top}:1)") + _create_var("t_tm", f"S({bot}:2,{top}:2)") + # Cross-pol transmission + _create_var("t_tm_te", f"S({bot}:2,{top}:1)") + _create_var("t_te_tm", f"S({bot}:1,{top}:2)") + # "Inverse" (swap ports) — needed for anisotropic RT tables + _create_var("r_te_inv", f"S({bot}:1,{bot}:1)") + _create_var("r_tm_inv", f"-S({bot}:2,{bot}:2)") + _create_var("r_tm_te_inv", f"-S({bot}:2,{bot}:1)") + _create_var("r_te_tm_inv", f"S({bot}:1,{bot}:2)") + _create_var("t_te_inv", f"S({top}:1,{bot}:1)") + _create_var("t_tm_inv", f"S({top}:2,{bot}:2)") + _create_var("t_tm_te_inv", f"S({top}:2,{bot}:1)") + _create_var("t_te_tm_inv", f"S({top}:1,{bot}:2)") + + # Port impedances (for scaling transmission) + _create_var(f"Zo_{floquet_ports[0]}_1", f"Zo({floquet_ports[0]}:1)") + _create_var(f"Zo_{floquet_ports[1]}_1", f"Zo({floquet_ports[1]}:1)") + return True + + @pyaedt_function_handler() + def get_fresnel_coefficients( + self, setup_sweep: str, theta_name: str, phi_name: str, output_file: Union[str, Path] = None + ) -> Path: + """ + Generate a Fresnel reflection or reflection/transmission coefficient table from simulation data. + + This method calculates the Fresnel reflection (and optionally transmission) coefficients for TE and TM modes + using S-parameters between Floquet ports in a HFSS simulation. The results are written to an ``.rttbl`` file in + a format compatible with SBR+ native tables. + + Parameters + ---------- + setup_sweep : str + Name of the setup and sweep. + theta_name : str + Name of the variation parameter representing the theta angle. + phi_name : str + Name of the variation parameter representing the phi angle. + output_file : str or :class:`pathlib.Path`, optional + Path to save the output ``.rttbl`` file. If not provided, a file will be generated automatically + in the toolkit directory. + + Returns + ------- + :class:`pathlib.Path` + The path to the generated `.rttbl` file containing Fresnel coefficients. + + """ + self.create_fresnel_variables(setup_sweep=setup_sweep) + + floquet_ports = self.get_fresnel_floquet_ports() + + file_name = f"fresnel_coefficients_{self.design_name}.rttbl" + output_file = Path(self.toolkit_directory, file_name) if output_file is None else Path(output_file) + + if output_file.is_file(): + new_name = f"{generate_unique_name('fresnel_coefficients', self.design_name)}.rttbl" + output_file = Path(self.toolkit_directory, new_name) + + # Determine if it is reflection-only (single Floquet port) or reflection/transmission (two ports) + is_reflection = len(floquet_ports) == 1 + + def _get_sd(varname: str): + return self.post.get_solution_data_per_variation( + "Modal Solution Data", setup_sweep, ["Domain:=", "Sweep"], variations, varname + ) + + variations = self.available_variations.all + variations["Freq"] = "All" + + r_te = _get_sd("r_te") + frequencies = r_te.primary_sweep_values + + # Isotropy check (same as original logic) + is_isotropic = True + if theta_name not in r_te.active_variation and phi_name not in r_te.active_variation: + raise AEDTRuntimeError("At least one scan should be performed on theta or phi.") + elif theta_name in r_te.active_variation and phi_name in r_te.active_variation: + is_isotropic = False + + # Load required datasets + r_tm = _get_sd("r_tm") + if not is_isotropic: + r_tm_te = _get_sd("r_tm_te") + r_te_tm = _get_sd("r_te_tm") + if not is_reflection: + r_te_inv = _get_sd("r_te_inv") + r_tm_inv = _get_sd("r_tm_inv") + r_tm_te_inv = _get_sd("r_tm_te_inv") + r_te_tm_inv = _get_sd("r_te_tm_inv") + + t_te = t_tm = None + if not is_reflection: + t_te = _get_sd("t_te") + t_tm = _get_sd("t_tm") + if not is_isotropic: + t_te_inv = _get_sd("t_te_inv") + t_tm_inv = _get_sd("t_tm_inv") + t_tm_te = _get_sd("t_tm_te") + t_tm_te_inv = _get_sd("t_tm_te_inv") + t_te_tm = _get_sd("t_te_tm") + t_te_tm_inv = _get_sd("t_te_tm_inv") + + # Port impedances for scaling (only when transmission exists) + if not is_reflection: + imp_top = _get_sd(f"Zo({floquet_ports[0]}:1)") + imp_bot = _get_sd(f"Zo({floquet_ports[1]}:1)") + + # Build angular grid and a variation index to avoid repeated scans + theta_max = 0.0 + phi_step = 0.0 + var_index = {} + is_360_defined = False + + if is_isotropic: + angles = {"0.0deg": []} + for var in r_te.variations: + th = var[theta_name] + if th > theta_max: + theta_max = th + angles["0.0deg"].append(th) + var_index[(th.value, None)] = var + theta_step = angles["0.0deg"][1] - angles["0.0deg"][0] + else: + angles = {} + phi_values = [] + for var in r_te.variations: + phi = var[phi_name] + theta = var[theta_name] + if theta > theta_max and theta.value <= 90.0: + theta_max = theta + key = f"{phi.value}{phi.unit}" + angles.setdefault(key, []).append(theta) + phi_values.append(phi.value) + var_index[(theta.value, phi.value)] = var + if 360.0 in phi_values: + is_360_defined = True + + theta_step = angles[f"{phi.value}{phi.unit}"][1] - angles[f"{phi.value}{phi.unit}"][0] + phi_step = phi_values[1] - phi_values[0] + + # Write output file + with open(output_file, "w") as ofile: + ofile.write("# SBR native file format for Fresnel reflection / reflection-transmission table data.\n") + ofile.write(f"# Generated from {self.project_name} project and {self.design_name} design.\n") + ofile.write( + "# The following key is critical in distinguishing between a reflection table and a" + " reflection/transmission table, as well as between isotropic and anisotropic notations.\n" + ) + + if is_reflection: + ofile.write("ReflTable\n" if is_isotropic else "AnisotropicReflTable\n") + else: + ofile.write("RTTable\n" if is_isotropic else "AnisotropicRTTable\n") + + ofile.write( + "# The incident angle theta is measured from the vertical (Z-axis) towards the horizon (XY-plane) " + "and must start from 0.\n" + ) + ofile.write("# Maximum simulated theta value, deg.\n") + if is_isotropic: + ofile.write(f"# ThetaMax {theta_max.value}\n") # until ThetaMax key becomes allowed + else: + ofile.write(f"ThetaMax {theta_max.value}\n") + + ofile.write("# The angular sampling is specified by the number of theta steps.\n") + ofile.write("# = number_of_theta_points – 1\n") + if is_isotropic: + nb_theta_points = int(90 / theta_step.value) # until ThetaMax key becomes allowed + else: + nb_theta_points = len(angles["0.0deg"]) - 1 + ofile.write(f"{nb_theta_points}\n") + ofile.write(f"# theta_step is {theta_step.value} {theta_step.unit}.\n") + if not is_isotropic: + ofile.write("# = number_of_phi_points – 1\n") + nb_phi_points = len(angles.keys()) + if is_360_defined: + nb_phi_points -= 1 + ofile.write(f"{nb_phi_points}\n") + ofile.write(f"# phi_step is {phi_step} deg.\n") + + ofile.write("# Frequency domain\n") + if len(frequencies) > 1: + ofile.write("# MultiFreq \n") + ofile.write(f"MultiFreq {frequencies[0].value} {frequencies[1].value} {len(frequencies) - 1}\n") + else: + freq = frequencies[0] + ofile.write(f"# Frequency-independent dataset. Simulated at {freq.value} {freq.unit}.\n") + ofile.write("MonoFreq\n") + + if is_isotropic: + ofile.write("# Data section follows. Frequency loops within theta.\n") + if is_reflection: + ofile.write("# \n") + else: + ofile.write("# \n") + else: + ofile.write("# Data section follows. Frequency loops within theta within phi.\n") + if is_reflection: + ofile.write( + "# " + "\n" + ) + else: + ofile.write( + "# " + " " + " \n" + ) + + if is_isotropic: + for theta in angles["0.0deg"]: + v = var_index[(theta.value, None)] + + # R TE + r_te.active_variation = v + re_r_te = r_te.data_real() + im_r_te = r_te.data_imag() + + # R TM + r_tm.active_variation = v + re_r_tm = r_tm.data_real() + im_r_tm = r_tm.data_imag() + + if is_reflection: + for i in range(len(frequencies)): + ofile.write(f"{re_r_te[i]:.5e}\t{im_r_te[i]:.5e}\t{re_r_tm[i]:.5e}\t{im_r_tm[i]:.5e}\n") + else: + # Impedance scaling factor (computed once for this theta) + imp_top.active_variation = v + imp_bot.active_variation = v + imp1_real = imp_top.data_real() + imp2_real = imp_bot.data_real() + factor = [math.sqrt(b / a) for a, b in zip(imp1_real, imp2_real)] + + # T TE + t_te.active_variation = v + re_t_te = [a * b for a, b in zip(t_te.data_real(), factor)] + im_t_te = [a * b for a, b in zip(t_te.data_imag(), factor)] + + # T TM + t_tm.active_variation = v + re_t_tm = [a * b for a, b in zip(t_tm.data_real(), factor)] + im_t_tm = [a * b for a, b in zip(t_tm.data_imag(), factor)] + + for i in range(len(frequencies)): + ofile.write( + f"{re_r_te[i]:.5e}\t{im_r_te[i]:.5e}\t" + f"{re_r_tm[i]:.5e}\t{im_r_tm[i]:.5e}\t" + f"{re_t_te[i]:.5e}\t{im_t_te[i]:.5e}\t" + f"{re_t_tm[i]:.5e}\t{im_t_tm[i]:.5e}\n" + ) + + if is_isotropic: # until ThetaMax key becomes allowed + # Isotropic coefficients must to until 90 deg + last_theta = angles["0.0deg"][-1] + if last_theta != 90: + next_theta = last_theta + theta_step + while next_theta.value <= 90.0: + next_theta += theta_step + for _ in frequencies: + if is_reflection: + ofile.write("\t".join(["0.0"] * 4) + "\n") + else: + ofile.write("\t".join(["0.0"] * 8) + "\n") + + else: + write_360 = [] + for phi_key, theta_list in angles.items(): + phi_q = Quantity(phi_key) + + for t in theta_list: + vkey = (t.value, phi_q.value) + + # R TE TE + r_te.active_variation = var_index[vkey] + re_r_te_te = r_te.data_real() + im_r_te_te = r_te.data_imag() + + # R TM TM + r_tm.active_variation = var_index[vkey] + re_r_tm_tm = r_tm.data_real() + im_r_tm_tm = r_tm.data_imag() + + # R TM TE + r_tm_te.active_variation = var_index[vkey] + re_r_tm_te = r_tm_te.data_real() + im_r_tm_te = r_tm_te.data_imag() + + # R TE TM + r_te_tm.active_variation = var_index[vkey] + re_r_te_tm = r_te_tm.data_real() + im_r_te_tm = r_te_tm.data_imag() + + if is_reflection: + for i in range(len(frequencies)): + output_str = ( + f"{re_r_te_te[i]:.5e}\t{im_r_te_te[i]:.5e}\t" + f"{re_r_tm_tm[i]:.5e}\t{im_r_tm_tm[i]:.5e}\t" + f"{re_r_tm_te[i]:.5e}\t{im_r_tm_te[i]:.5e}\t" + f"{re_r_te_tm[i]:.5e}\t{im_r_te_tm[i]:.5e}\n" + ) + if phi_q.value == 0.0 and not is_360_defined: + # Duplicate phi 0 for the 360 case + write_360.append(output_str) + ofile.write(output_str) + else: + # Impedance scaling factor (computed once per (theta, phi)) + imp_top.active_variation = var_index[vkey] + imp_bot.active_variation = var_index[vkey] + imp1_real = imp_top.data_real() + imp2_real = imp_bot.data_real() + factor = [math.sqrt(b / a) for a, b in zip(imp2_real, imp1_real)] + + # T TE TE + t_te.active_variation = var_index[vkey] + re_t_te_te = [a * b for a, b in zip(t_te.data_real(), factor)] + im_t_te_te = [a * b for a, b in zip(t_te.data_imag(), factor)] + + # T TM TM + t_tm.active_variation = var_index[vkey] + re_t_tm_tm = [a * b for a, b in zip(t_tm.data_real(), factor)] + im_t_tm_tm = [a * b for a, b in zip(t_tm.data_imag(), factor)] + + # T TM TE + t_tm_te.active_variation = var_index[vkey] + re_t_tm_te = [a * b for a, b in zip(t_tm_te.data_real(), factor)] + im_t_tm_te = [a * b for a, b in zip(t_tm_te.data_imag(), factor)] + + # T TE TM + t_te_tm.active_variation = var_index[vkey] + re_t_te_tm = [a * b for a, b in zip(t_te_tm.data_real(), factor)] + im_t_te_tm = [a * b for a, b in zip(t_te_tm.data_imag(), factor)] + + for i in range(len(frequencies)): + output_str = ( + f"{re_r_te_te[i]:.5e}\t{im_r_te_te[i]:.5e}\t" + f"{re_r_tm_tm[i]:.5e}\t{im_r_tm_tm[i]:.5e}\t" + f"{re_r_tm_te[i]:.5e}\t{im_r_tm_te[i]:.5e}\t" + f"{re_r_te_tm[i]:.5e}\t{im_r_te_tm[i]:.5e}\t" + f"{re_t_te_te[i]:.5e}\t{im_t_te_te[i]:.5e}\t" + f"{re_t_tm_tm[i]:.5e}\t{im_t_tm_tm[i]:.5e}\t" + f"{re_t_tm_te[i]:.5e}\t{im_t_tm_te[i]:.5e}\t" + f"{re_t_te_tm[i]:.5e}\t{im_t_te_tm[i]:.5e}\n" + ) + if phi_q.value == 0.0 and not is_360_defined: + # Duplicate phi 0 for the 360 case + write_360.append(output_str) + ofile.write(output_str) + + if not is_reflection: + theta_list.reverse() + for t in theta_list: + vkey = (t.value, phi_q.value) + # R TE TE (inverse) + r_te_inv.active_variation = var_index[vkey] + re_r_te_te = r_te_inv.data_real() + im_r_te_te = r_te_inv.data_imag() + + # R TM TM (inverse) + r_tm_inv.active_variation = var_index[vkey] + re_r_tm_tm = r_tm_inv.data_real() + im_r_tm_tm = r_tm_inv.data_imag() + + # R TM TE (inverse) + r_tm_te_inv.active_variation = var_index[vkey] + re_r_tm_te = r_tm_te_inv.data_real() + im_r_tm_te = r_tm_te_inv.data_imag() + + # R TE TM (inverse) + r_te_tm_inv.active_variation = var_index[vkey] + re_r_te_tm = r_te_tm_inv.data_real() + im_r_te_tm = r_te_tm_inv.data_imag() + + # Impedance scaling factor for inverse part (mirrors original direction) + imp_top.active_variation = var_index[vkey] + imp_bot.active_variation = var_index[vkey] + imp1_real = imp_top.data_real() + imp2_real = imp_bot.data_real() + factor = [math.sqrt(a / b) for a, b in zip(imp1_real, imp2_real)] + + # T TE TE (inverse) + t_te_inv.active_variation = var_index[vkey] + re_t_te_te = [a * b for a, b in zip(t_te_inv.data_real(), factor)] + im_t_te_te = [a * b for a, b in zip(t_te_inv.data_imag(), factor)] + + # T TM TM (inverse) + t_tm_inv.active_variation = var_index[vkey] + re_t_tm_tm = [a * b for a, b in zip(t_tm_inv.data_real(), factor)] + im_t_tm_tm = [a * b for a, b in zip(t_tm_inv.data_imag(), factor)] + + # T TM TE (inverse) + t_tm_te_inv.active_variation = var_index[vkey] + re_t_tm_te = [a * b for a, b in zip(t_tm_te_inv.data_real(), factor)] + im_t_tm_te = [a * b for a, b in zip(t_tm_te_inv.data_imag(), factor)] + + # T TE TM (inverse) + t_te_tm_inv.active_variation = var_index[vkey] + re_t_te_tm = [a * b for a, b in zip(t_te_tm_inv.data_real(), factor)] + im_t_te_tm = [a * b for a, b in zip(t_te_tm_inv.data_imag(), factor)] + + for i in range(len(frequencies)): + output_str = ( + f"{re_r_te_te[i]:.5e}\t{im_r_te_te[i]:.5e}\t" + f"{re_r_tm_tm[i]:.5e}\t{im_r_tm_tm[i]:.5e}\t" + f"{re_r_tm_te[i]:.5e}\t{im_r_tm_te[i]:.5e}\t" + f"{re_r_te_tm[i]:.5e}\t{im_r_te_tm[i]:.5e}\t" + f"{re_t_te_te[i]:.5e}\t{im_t_te_te[i]:.5e}\t" + f"{re_t_tm_tm[i]:.5e}\t{im_t_tm_tm[i]:.5e}\t" + f"{re_t_tm_te[i]:.5e}\t{im_t_tm_te[i]:.5e}\t" + f"{re_t_te_tm[i]:.5e}\t{im_t_te_tm[i]:.5e}\n" + ) + if phi_q.value == 0.0 and not is_360_defined: + # Duplicate phi 0 for the 360 case + write_360.append(output_str) + ofile.write(output_str) + + if not is_360_defined: + for phi_360_str in write_360: + ofile.write(phi_360_str) + + return output_file + + @pyaedt_function_handler() + def get_fresnel_floquet_ports(self) -> List[str]: + """ + Identify and validate Floquet ports from excitation names. + + This method extracts port and mode information from the excitation names, + checks that each port has exactly two modes, and identifies the top and + bottom ports based on their bounding box Z-coordinate. The function + supports only two Floquet ports. If more than two ports are defined or + the mode conditions are not met, appropriate errors are logged. + + Returns + ------- + list of str + A list containing the names of the top and bottom Floquet ports, + identified by their vertical position (Z-axis). If validation fails, + returns ``None``. + """ + port_mode_list = self.excitation_names + + ports = defaultdict(set) + + for item in port_mode_list: + if ":" in item: + port_name, mode = item.split(":") + ports[port_name].add(int(mode)) + + if len(ports) > 2: + raise AEDTRuntimeError("More than 2 Floquet ports defined.") + + for port, modes in ports.items(): + if len(modes) < 2: + raise AEDTRuntimeError( + f"Number of modes in {port} must be at least 2 (higher modes are ignored for processing)." + ) + + if len(ports) == 1: + return list(ports.keys()) + + excitations = self.design_excitations + top_port = None + bot_port = None + top_pos = None + for exc in excitations.values(): + assignment = exc.properties["Assignment"] + if "(Face_" in assignment: + face_id = int(re.search(r"Face_(\d+)", assignment).group(1)) + bounding_box = self.modeler.get_face_center(face_id) + else: + assigment_object = self.modeler[assignment] + bounding_box = assigment_object.bounding_box + + if top_port is None or top_pos is None: + top_port = exc.name + top_pos = bounding_box[2] + elif top_pos < bounding_box[2]: + bot_port = top_port + top_port = exc.name + top_pos = bounding_box[2] + else: + bot_port = exc.name + + return [top_port, bot_port] diff --git a/src/ansys/aedt/core/modules/design_xploration.py b/src/ansys/aedt/core/modules/design_xploration.py index ac53fb3e79d..e1f40b92574 100644 --- a/src/ansys/aedt/core/modules/design_xploration.py +++ b/src/ansys/aedt/core/modules/design_xploration.py @@ -106,7 +106,7 @@ def __init__(self, p_app, name, dictinputs, optimtype): if isinstance(self._app.design_properties["SolutionManager"]["ID Map"]["Setup"], list): for setup in self._app.design_properties["SolutionManager"]["ID Map"]["Setup"]: if setup["I"] == el: - setups[setups.index(el)] = setup["I"] + setups[setups.index(el)] = setup["N"] break else: if self._app.design_properties["SolutionManager"]["ID Map"]["Setup"]["I"] == el: @@ -114,6 +114,7 @@ def __init__(self, p_app, name, dictinputs, optimtype): "Setup" ]["N"] break + except (TypeError, KeyError): pass diff --git a/src/ansys/aedt/core/visualization/post/solution_data.py b/src/ansys/aedt/core/visualization/post/solution_data.py index 09c76a63c2b..e5c5002bace 100644 --- a/src/ansys/aedt/core/visualization/post/solution_data.py +++ b/src/ansys/aedt/core/visualization/post/solution_data.py @@ -37,6 +37,7 @@ from ansys.aedt.core.generic.file_utils import write_csv from ansys.aedt.core.generic.general_methods import pyaedt_function_handler from ansys.aedt.core.generic.settings import settings +from ansys.aedt.core.internal.errors import AEDTRuntimeError try: import pandas as pd @@ -123,7 +124,30 @@ def _get_variations(self): for data in self._original_data: variations = {} for v in data.GetDesignVariableNames(): - variations[v] = Quantity(data.GetDesignVariableValue(v), data.GetDesignVariableUnits(v)) + # TODO: If data is degrees, AEDT return the variable value as radians + variation_key = data.GetDesignVariationKey() + variable_units = data.GetDesignVariableUnits(v) + variable_value = float(data.GetDesignVariableValue(v)) + + # If variation contains more than one parameter, a name filter is needed + variation_key = variation_key.split(";") + + value_from_key = None + + for var in variation_key: + var_name = var.split("=")[0] + var_value = var.split("=")[1] + if var_name == v: + value_from_key_w_units = var_value.strip() + value_from_key = float(value_from_key_w_units.split(variable_units)[0]) + break + + if variable_value != value_from_key: + variable_value = value_from_key + elif value_from_key is None: # pragma: no cover + raise AEDTRuntimeError(f"Value of {v} can not be obtained.") + + variations[v] = Quantity(variable_value, variable_units) variations_lists.append(variations) return variations_lists diff --git a/tests/system/general/example_models/T20/fresnel_test.aedtz b/tests/system/general/example_models/T20/fresnel_test.aedtz new file mode 100644 index 00000000000..dcf8a970c12 Binary files /dev/null and b/tests/system/general/example_models/T20/fresnel_test.aedtz differ diff --git a/tests/system/general/test_20_HFSS.py b/tests/system/general/test_20_HFSS.py index a417a279558..f14b6f457e7 100644 --- a/tests/system/general/test_20_HFSS.py +++ b/tests/system/general/test_20_HFSS.py @@ -24,11 +24,13 @@ import math import os +from pathlib import Path import re import shutil import pytest +from ansys.aedt.core import Hfss from ansys.aedt.core.generic.constants import Axis from ansys.aedt.core.generic.constants import Plane from ansys.aedt.core.internal.errors import AEDTRuntimeError @@ -45,13 +47,13 @@ component = "RectProbe_ATK_251.a3dcomp" - if config["desktopVersion"] > "2023.1": diff_proj_name = "differential_pairs_231" else: diff_proj_name = "differential_pairs" component_array = "Array_232" +fresnel_name = "fresnel_test" @pytest.fixture(scope="class") @@ -66,6 +68,13 @@ def fall_back_name(aedtapp): return name +@pytest.fixture() +def fresnel(add_app): + app = add_app(application=Hfss, project_name=fresnel_name, subfolder=test_subfolder) + yield app + app.close_project(app.project_name) + + class TestClass: @pytest.fixture(autouse=True) def init(self, aedtapp, fall_back_name, local_scratch): @@ -2083,3 +2092,53 @@ def test_convert_far_field(self): convert_farfield_data("non_existing_file.ffs") with pytest.raises(FileNotFoundError): convert_farfield_data("non_existing_file.ffe") + + def test_get_fresnel_floquet_ports(self, fresnel): + fresnel.design_name = "two_ports" + ports = fresnel.get_fresnel_floquet_ports() + assert ports[0] == "Ptop" + assert len(ports) == 2 + + fresnel.design_name = "one_port" + ports = fresnel.get_fresnel_floquet_ports() + assert len(ports) == 1 + + fresnel.design_name = "three_ports" + with pytest.raises(AEDTRuntimeError): + fresnel.get_fresnel_floquet_ports() + + fresnel.design_name = "wrong_modes" + with pytest.raises(AEDTRuntimeError): + fresnel.get_fresnel_floquet_ports() + + def test_get_fresnel_coefficients(self, fresnel): + fresnel.design_name = "two_ports" + name = f"fresnel_coefficients_{fresnel.design_name}.rttbl" + file_name = Path(fresnel.toolkit_directory) / name + + # Multi frequency + output1 = fresnel.get_fresnel_coefficients(setup_sweep="Setup2 : Sweep", theta_name="scan_T", phi_name="scan_P") + assert output1.is_file() + + # Duplicated file + output2 = fresnel.get_fresnel_coefficients( + setup_sweep="Setup2 : Sweep", theta_name="scan_T", phi_name="scan_P", output_file=file_name + ) + assert output2 != output1 + + # Reflection and Single Freq + fresnel.design_name = "one_port" + output3 = fresnel.get_fresnel_coefficients(setup_sweep="Setup : Sweep", theta_name="scan_T", phi_name="scan_P") + assert output3.is_file() + + # No parametric sweep + fresnel.design_name = "one_port" + with pytest.raises(AEDTRuntimeError): + fresnel.get_fresnel_coefficients( + setup_sweep="no_param : LastAdaptive", theta_name="scan_T", phi_name="scan_P" + ) + + # No correct floquet ports + fresnel.design_name = "three_ports" + with pytest.raises(AEDTRuntimeError): + fresnel.get_fresnel_coefficients(setup_sweep="Setup2 : Sweep", theta_name="scan_T", phi_name="scan_P") diff --git a/tests/unit/extensions/test_fresnel.py b/tests/unit/extensions/test_fresnel.py new file mode 100644 index 00000000000..1315f92dbde --- /dev/null +++ b/tests/unit/extensions/test_fresnel.py @@ -0,0 +1,124 @@ +# -*- 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 unittest.mock import MagicMock + +import pytest + +from ansys.aedt.core.extensions.hfss.fresnel import EXTENSION_TITLE +from ansys.aedt.core.extensions.hfss.fresnel import FresnelExtension +from ansys.aedt.core.generic.numbers import Quantity + + +@pytest.fixture +def mock_hfss_app_fresnel(mock_hfss_app): + """Fixture to create a mock AEDT application (extends HFSS mock).""" + mock_hfss_app.design_setups = {} + + mock_setup = MagicMock() + mock_setup.properties = {"Solution Freq": Quantity("1GHz")} + + mock_hfss_app.design_setups = {"Setup": mock_setup} + + yield mock_hfss_app + + +def test_default(mock_hfss_app_fresnel): + """Test instantiation of the Fresnel extension.""" + mock_hfss_app_fresnel.design_setups = {} + extension = FresnelExtension(withdraw=True) + + assert EXTENSION_TITLE == extension.root.title() + assert "light" == extension.root.theme + + extension.root.destroy() + + +def test_mode(mock_hfss_app_fresnel): + """Test coefficient mode.""" + extension = FresnelExtension(withdraw=True) + + # Select advanced workflow tab + tabs = extension._widgets["tabs"] + tabs.select(1) + + isotropic_button = extension._widgets["isotropic_button"] + anisotropic_button = extension._widgets["anisotropic_button"] + isotropic_button.invoke() + assert extension._widgets["azimuth_slider"].grid_info() != {} + assert extension._widgets["azimuth_spin"].grid_info() != {} + assert extension._widgets["azimuth_label"].grid_info() != {} + + anisotropic_button.invoke() + assert extension._widgets["azimuth_slider"].grid_info() == {} + assert extension._widgets["azimuth_spin"].grid_info() == {} + assert extension._widgets["azimuth_label"].grid_info() == {} + + extension.root.destroy() + + +def test_elevation_slider_changed_updates_values(mock_hfss_app_fresnel): + extension = FresnelExtension(withdraw=True) + + # Select advanced workflow tab + tabs = extension._widgets["tabs"] + tabs.select(1) + anisotropic_button = extension._widgets["anisotropic_button"] + anisotropic_button.invoke() + + extension._widgets["elevation_resolution"].set(-5) + assert not extension.update_elevation_max_constraints() + + extension.root.destroy() + + +def test_apply_validate_no_setup(mock_hfss_app_fresnel): + mock_hfss_app_fresnel.design_setups = {} + extension = FresnelExtension(withdraw=True) + + # Select advanced workflow tab + tabs = extension._widgets["tabs"] + tabs.select(1) + anisotropic_button = extension._widgets["anisotropic_button"] + anisotropic_button.invoke() + + apply_button = extension._widgets["apply_validate_button"] + apply_button.invoke() + + extension.root.destroy() + + +def test_apply_validate(mock_hfss_app_fresnel): + extension = FresnelExtension(withdraw=True) + + # Select advanced workflow tab + tabs = extension._widgets["tabs"] + tabs.select(1) + anisotropic_button = extension._widgets["anisotropic_button"] + anisotropic_button.invoke() + + apply_button = extension._widgets["apply_validate_button"] + apply_button.invoke() + + extension.root.destroy()