Source code for qpdk.simulation.q3d

"""Q3D and Q2D simulation utilities using PyAEDT."""

from __future__ import annotations

import math
from pathlib import Path
from typing import TYPE_CHECKING, cast

import gdsfactory as gf
import polars as pl

from qpdk import LAYER_STACK
from qpdk.models.cpw import get_cpw_dimensions
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 Q2d
    from ansys.aedt.core.q3d import Q3d
    from gdsfactory.component import Component
    from gdsfactory.technology import LayerStack
    from gdsfactory.typings import CrossSectionSpec, Ports


[docs] class Q3D(AEDTBase): """Q3D Extractor simulation wrapper. Provides methods for importing components into Q3D and performing parasitic capacitance/inductance extraction. """ def __init__(self, q3d: Q3d): """Initialize the Q3D wrapper. Args: q3d: The PyAEDT Q3d application instance. """ super().__init__(q3d) self.q3d = q3d
[docs] def import_component( self, component: Component, layer_stack: LayerStack | None = None, *, units: str = "um", gds_path: str | Path | None = None, ) -> list[str]: """Import a gdsfactory component into Q3D Extractor. Imports the component's GDS geometry into a Q3D Extractor project, mapping each GDS layer to a 3D conductor at the appropriate elevation and thickness from the layer stack. 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. 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: List of newly created conductor object names in Q3D. Raises: RuntimeError: If GDS import fails. """ mapping_layers = layer_stack_to_gds_mapping(layer_stack) with export_component_to_gds_temp( component, gds_path, prefix="qpdk_q3d_" ) as path: self.modeler.model_units = units existing_objects = set(self.modeler.object_names) result = self.q3d.import_gds_3d( input_file=str(path), mapping_layers=mapping_layers, units=units, import_method=0, ) if not result: raise RuntimeError("Q3D GDS import failed") new_objects = list(set(self.modeler.object_names) - existing_objects) stack = layer_stack or LAYER_STACK renamed_objects = rename_imported_objects(self.q3d, new_objects, stack) # Assign materials from the layer stack: metals become "pec", # substrate/etch objects get their real dielectric materials. # Batched per material to keep AEDT round trips minimal. self.add_materials() objects_to_materials = object_names_to_materials(renamed_objects, stack) by_material: dict[str, list[str]] = {} for obj_name, material in objects_to_materials.items(): by_material.setdefault(material, []).append(obj_name) for material, objects in by_material.items(): self.q3d.assign_material(objects, material) # Only conductors are meaningful downstream (e.g. net assignment). return [ obj_name for obj_name, material in objects_to_materials.items() if material == "pec" ]
[docs] def assign_nets_from_ports( self, ports: Ports, conductor_objects: list[str], ) -> list[str]: """Assign Q3D signal nets based on gdsfactory port locations. For each gdsfactory port, finds the conductor object whose bounding-box center is nearest to the port center and assigns it as a Q3D signal net. Args: ports: Collection of gdsfactory ports defining signal locations. conductor_objects: List of conductor object names created by :meth:`import_component`. Returns: List of assigned signal net names (one per port). """ self.q3d.auto_identify_nets() assigned_nets: list[str] = [] used_objects: set[str] = set() if not ports or not conductor_objects: return assigned_nets bboxes = {} for obj_name in conductor_objects: obj = self.modeler.get_object_from_name(obj_name) if obj: bboxes[obj_name] = obj.bounding_box if not bboxes: return assigned_nets first_port = next(iter(ports)) px0, py0 = float(first_port.center[0]), float(first_port.center[1]) def dist_to_bbox( px: float, py: float, bbox: list[float], s: float = 1.0 ) -> float: dx = max(bbox[0] * s - px, 0, px - bbox[3] * s) dy = max(bbox[1] * s - py, 0, py - bbox[4] * s) return math.hypot(dx, dy) scale_factor = min( (10**p for p in range(-3, 5)), key=lambda s: min(dist_to_bbox(px0, py0, b, s) for b in bboxes.values()), ) for port in ports: px, py = float(port.center[0]), float(port.center[1]) available_objs = [obj for obj in bboxes if obj not in used_objects] if not available_objs: break def port_metric( obj_name: str, px: float = px, py: float = py ) -> tuple[float, float]: b = bboxes[obj_name] dist = dist_to_bbox(px, py, b, scale_factor) area = (b[3] - b[0]) * (b[4] - b[1]) * scale_factor**2 return max(0.0, dist - 1.0), area best_obj = min(available_objs, key=port_metric) net_to_rename = next( ( b for b in self.q3d.boundaries if b.type == "SignalNet" and best_obj in b.props.get("Objects", []) ), None, ) if net_to_rename is not None: net_to_rename.name = port.name else: self.q3d.assign_net( assignment=[best_obj], net_name=port.name, net_type="Signal" ) assigned_nets.append(port.name) used_objects.add(best_obj) return assigned_nets
[docs] def get_capacitance_matrix(self, setup_name: str = "Q3DSetup") -> pl.DataFrame: """Extract the capacitance matrix from a Q3D Extractor simulation. Retrieves all capacitance matrix entries (e.g. ``C(o1,o1)``, ``C(o1,o2)``) from the solved Q3D setup. Args: setup_name: Name of the analysis setup. Returns: DataFrame with one column per capacitance expression containing the extracted values in Farads. """ nets = [b.name for b in self.q3d.boundaries if b.type == "SignalNet"] expressions = [f"C({n1},{n2})" for i, n1 in enumerate(nets) for n2 in nets[i:]] data: dict[str, list[float]] = {} for expr in expressions: solution = self.q3d.post.get_solution_data( expressions=expr, setup_sweep_name=f"{setup_name} : LastAdaptive", ) if solution and (val := _first_real_value(solution)) is not None: unit = solution.units_data.get(expr, "pF") multiplier = { "fF": 1e-15, "pF": 1e-12, "nF": 1e-9, "uF": 1e-6, "mF": 1e-3, "F": 1.0, }.get(str(unit), 1e-12) data[expr] = [val * multiplier] return pl.DataFrame(data)
[docs] class Q2D(AEDTBase): """Q2D simulation wrapper. Provides methods for 2D cross-sectional impedance extraction. """ def __init__(self, q2d: Q2d): """Initialize the Q2D wrapper. Args: q2d: The PyAEDT Q2d application instance. """ super().__init__(q2d) self.q2d = q2d
[docs] def create_2d_from_cross_section( self, cross_section: CrossSectionSpec, layer_stack: LayerStack | None = None, *, ground_width: float | None = None, units: str = "um", ) -> dict[str, str]: """Create a 2D model from a CPW cross-section for impedance extraction. Builds the cross-sectional geometry of a coplanar waveguide in Ansys Q2D (2D Extractor). Args: cross_section: A gdsfactory cross-section specification describing the CPW geometry (width and gap). layer_stack: LayerStack defining substrate and conductor properties. If None, uses QPDK's default ``LAYER_STACK``. ground_width: Width of each coplanar ground plane in µm. If None, defaults to 10× the CPW gap. units: Length units for the Q2D geometry (default ``"um"``). Returns: Dictionary with keys ``"signal"``, ``"gnd_left"``, ``"gnd_right"``, ``"substrate"`` mapping to the created Q2D object names. Raises: ValueError: If cross-section mapping fails or dimensions are invalid. """ if layer_stack is None: layer_stack = LAYER_STACK if units != "um": raise ValueError("Q2D cross-section expects units='um'") cpw_width, cpw_gap = get_cpw_dimensions(cross_section) substrate_level = layer_stack.layers["Substrate"] substrate_thickness = float(substrate_level.thickness) substrate_material = cast(str, substrate_level.material) conductor_level = layer_stack.layers["M1"] conductor_thickness = float(conductor_level.thickness) if conductor_thickness < 2.0: gf.logger.warning( "Setting conductor_thickness to 2.0 um for Q2D stability." ) conductor_thickness = 2.0 conductor_material = cast(str, conductor_level.material) if ground_width is None: ground_width = 10.0 * cpw_gap self.add_materials() self.modeler.model_units = units total_width = 2 * ground_width + 2 * cpw_gap + cpw_width substrate_margin = 50.0 parts = [ { "name": "signal", "origin": [ground_width + cpw_gap, 0, 0], "sizes": [cpw_width, conductor_thickness], "material": conductor_material, }, { "name": "gnd_left", "origin": [0, 0, 0], "sizes": [ground_width, conductor_thickness], "material": conductor_material, }, { "name": "gnd_right", "origin": [ground_width + cpw_gap + cpw_width + cpw_gap, 0, 0], "sizes": [ground_width, conductor_thickness], "material": conductor_material, }, { "name": "substrate", "origin": [-substrate_margin, -substrate_thickness, 0], "sizes": [total_width + 2 * substrate_margin, substrate_thickness], "material": substrate_material, }, ] objects = { part["name"]: self.modeler.create_rectangle(**part) for part in parts } self.q2d.assign_single_conductor( name="signal", assignment=[objects["signal"]], conductor_type="SignalLine", units=units, ) self.q2d.assign_single_conductor( name="gnd", assignment=[objects["gnd_left"], objects["gnd_right"]], conductor_type="ReferenceGround", units=units, ) self.app.mesh.assign_length_mesh( assignment=[objects["signal"], objects["gnd_left"], objects["gnd_right"]], maximum_length=2.0, maximum_elements=10000, name="thin_trace_mesh", ) return {str(name): obj.name for name, obj in objects.items()}