Source code for qpdk.simulation.hfss

"""HFSS simulation utilities using PyAEDT."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, TypedDict

import numpy as np
import polars as pl

from qpdk import LAYER_STACK
from qpdk.simulation.aedt_base import (
    AEDTBase,
    _first_real_value,
    export_component_to_gds_temp,
    layer_stack_to_gds_mapping,
    object_names_to_materials,
    rename_imported_objects,
)

if TYPE_CHECKING:
    from ansys.aedt.core import Hfss
    from gdsfactory.component import Component
    from gdsfactory.technology import LayerStack
    from gdsfactory.typings import Ports


# TODO(Python 3.13): wrap each key in typing.ReadOnly (PEP 705); never mutated.
# TODO(Python 3.15): add `closed=True` (PEP 728) to reject misspelled keys.
[docs] class LumpedPortConfig(TypedDict): """Configuration for defining a lumped port rectangle in HFSS.""" origin: list[float] sizes: list[float] integration_line: list[list[float]]
[docs] def lumped_port_rectangle_from_cpw( center: tuple[float, float, float], orientation: float, cpw_gap: float, cpw_width: float, ) -> LumpedPortConfig: """Calculates parameters for a lumped port based on its orientation. Args: center: [x, y, z] coordinates of the port face center. orientation: Angle in degrees (must be a multiple of 90). cpw_gap: The length of the port along the axis of propagation. cpw_width: The width of the port perpendicular to propagation. Returns: A dictionary containing 'origin', 'sizes', and 'integration_line' for HFSS. Raises: ValueError: If port orientation is not a multiple of 90 degrees. """ if orientation % 90 != 0: raise ValueError(f"Unsupported port orientation: {orientation}°") cx, cy = center[0], center[1] theta = np.deg2rad(orientation) c = np.round(np.cos(theta)) s = np.round(np.sin(theta)) size_x = cpw_gap * np.abs(c) + cpw_width * np.abs(s) size_y = cpw_width * np.abs(c) + cpw_gap * np.abs(s) rect_cx = cx + (cpw_gap / 2) * c rect_cy = cy + (cpw_gap / 2) * s origin = [rect_cx - size_x / 2, rect_cy - size_y / 2, 0] int_line = [[cx + cpw_gap * c, cy + cpw_gap * s, 0], [cx, cy, 0]] return {"origin": origin, "sizes": [size_x, size_y], "integration_line": int_line}
[docs] class HFSS(AEDTBase): """HFSS simulation wrapper. Provides high-level methods for importing components into HFSS, setting up simulation regions, and extracting results. """ def __init__(self, hfss: Hfss): """Initialize the HFSS wrapper. Args: hfss: The PyAEDT Hfss application instance. """ super().__init__(hfss) self.hfss = hfss
[docs] def import_component( self, component: Component, layer_stack: LayerStack | None = None, *, import_as_sheets: bool = False, units: str = "um", gds_path: str | Path | None = None, ) -> bool: """Import a gdsfactory component into HFSS. Args: component: The gdsfactory component to import. layer_stack: LayerStack defining thickness and elevation for each layer. If None, uses QPDK's default LAYER_STACK. import_as_sheets: If True, imports metals as 2D sheets (zero thickness) and assigns PerfectE boundary to them. If False, imports as 3D objects with thickness from layer_stack and assigns PerfectE boundary to their surfaces. units: Length units for the geometry (default: "um" for micrometers). gds_path: Optional path to write the GDS file. If None, uses a temporary file. Returns: True if import was successful, False otherwise. """ thickness_override = 0.0 if import_as_sheets else None mapping_layers = layer_stack_to_gds_mapping( layer_stack, thickness_override=thickness_override ) with export_component_to_gds_temp( component, gds_path, prefix="qpdk_hfss_" ) as path: self.modeler.model_units = units existing_objects = set(self.modeler.object_names) result = self.hfss.import_gds_3d( input_file=str(path), mapping_layers=mapping_layers, units=units, import_method=0, ) if result: new_objects = list(set(self.modeler.object_names) - existing_objects) stack = layer_stack or LAYER_STACK renamed_objects = rename_imported_objects(self.hfss, new_objects, stack) # PerfectE is a conductor boundary: assign it only to metal # levels, not to dielectric objects such as the substrate. conductor_objects = [ obj_name for obj_name, material in object_names_to_materials( renamed_objects, stack ).items() if material == "pec" ] if conductor_objects: if import_as_sheets: self.hfss.assign_perfecte_to_sheets( conductor_objects, name="PEC_Sheets" ) else: self.hfss.assign_perfect_e(conductor_objects, name="PEC_3D") return result
[docs] def add_lumped_ports(self, ports: Ports, cpw_gap: float, cpw_width: float) -> None: """Add lumped ports to HFSS at given port locations. Args: ports: Collection of gdsfactory ports defining signal locations. cpw_gap: The length of the port along the axis of propagation. cpw_width: The width of the port perpendicular to propagation. """ for port in ports: params = lumped_port_rectangle_from_cpw( port.center, port.orientation, cpw_gap, cpw_width ) port_rect = self.modeler.create_rectangle( orientation="XY", name=f"{port.name}_face", **params ) self.hfss.lumped_port( assignment=port_rect.name, name=port.name, create_port_sheet=False, integration_line=params["integration_line"], )
[docs] def add_air_region( self, component: Component, height: float = 500.0, substrate_thickness: float = 500.0, pec_boundary: bool = False, name: str = "AirRegion", ) -> str: """Add an air region (vacuum box) around the component. Args: component: The component to create air region around. height: Height above the component in micrometers. substrate_thickness: Depth below surface for the region. pec_boundary: If True, assign PerfectE boundary conditions to outer faces. name: Name of the created region object. Returns: Name of the created region object. """ bounds = component.bbox() x_min, y_min = bounds.p1.x, bounds.p1.y dx, dy = bounds.p2.x - x_min, bounds.p2.y - y_min region = self.modeler.create_box( origin=[x_min, y_min, -substrate_thickness], sizes=[dx, dy, height + substrate_thickness], name=name, material="vacuum", ) region.mesh_order = 99 if pec_boundary: self.hfss.assign_perfect_e( assignment=[face.id for face in region.faces], name="PEC_Boundary", ) return region.name
[docs] def get_eigenmode_results(self, setup_name: str = "EigenmodeSetup") -> dict: """Extract eigenmode simulation results. Args: setup_name: Name of the setup to get results from. Returns: Dictionary containing: - frequencies: List of eigenmode frequencies in GHz - q_factors: List of Q factors for each mode """ # Get frequency values freq_names = self.hfss.post.available_report_quantities( quantities_category="Eigen Modes" ) q_names = self.hfss.post.available_report_quantities( quantities_category="Eigen Q" ) results = {"frequencies": [], "q_factors": [], "setup": setup_name} for f_name in freq_names: solution = self.hfss.post.get_solution_data( expressions=f_name, report_category="Eigenmode" ) if solution and (freq_hz := _first_real_value(solution)) is not None: results["frequencies"].append(freq_hz / 1e9) for q_name in q_names: solution = self.hfss.post.get_solution_data( expressions=q_name, report_category="Eigenmode" ) if solution and (q := _first_real_value(solution)) is not None: results["q_factors"].append(q) return results
[docs] def get_sparameter_results( self, setup_name: str = "DrivenSetup", sweep_name: str = "FrequencySweep" ) -> pl.DataFrame: """Extract S-parameter results from a driven simulation. Args: setup_name: Name of the setup. sweep_name: Name of the frequency sweep. Returns: DataFrame containing a 'frequency_ghz' column and a column for each S-parameter trace (e.g., "S(1,1)") containing complex values. """ traces = self.hfss.get_traces_for_plot() data = {} for trace in traces: solution = self.hfss.post.get_solution_data( expressions=trace, setup_sweep_name=f"{setup_name} : {sweep_name}", ) if solution: if "frequency_ghz" not in data: data["frequency_ghz"] = np.array(solution.primary_sweep_values) # Use get_expression_data to get real and imaginary parts _, real_data = solution.get_expression_data(formula="real") _, imag_data = solution.get_expression_data(formula="imag") data[trace] = real_data + 1j * imag_data return pl.DataFrame(data)