"""Base AEDT simulation utilities using PyAEDT.
This module provides shared helper functions and a base class for AEDT
simulations (HFSS, Q3D, Q2D) from gdsfactory components.
"""
from __future__ import annotations
import re
import tempfile
from collections.abc import Generator
from contextlib import contextmanager
from operator import itemgetter
from pathlib import Path
from typing import TYPE_CHECKING, Any
import gdsfactory as gf
from gdsfactory.technology.layer_stack import LayerLevel
from qpdk import LAYER_STACK, logger
from qpdk.simulation.layout import prepare_metal_layout
from qpdk.singleton import SingletonMeta
from qpdk.tech import material_properties
if TYPE_CHECKING:
from ansys.aedt.core import Hfss, Q2d
from ansys.aedt.core.q3d import Q3d
from gdsfactory.component import Component
from gdsfactory.technology import LayerStack
def _get_layer_number_from_level(layer_level: LayerLevel) -> int | None:
"""Extract layer number from a LayerLevel's layer definition.
Returns:
The GDS layer number if available, else None.
"""
if hasattr(layer_level, "derived_layer") and layer_level.derived_layer is not None:
derived = layer_level.derived_layer
if hasattr(derived, "layer"):
inner = derived.layer
if hasattr(inner, "layer"):
val = inner.layer
if isinstance(val, tuple) and len(val) >= 1:
return int(val[0])
return int(val)
if isinstance(inner, tuple) and len(inner) >= 1:
return int(inner[0])
layer = layer_level.layer
if isinstance(layer, tuple) and len(layer) >= 1:
return int(layer[0])
if hasattr(layer, "layer"):
inner = layer.layer
if isinstance(inner, tuple) and len(inner) >= 1:
return int(inner[0])
if hasattr(inner, "layer"):
val = inner.layer
if isinstance(val, tuple) and len(val) >= 1:
return int(val[0])
return int(val)
return int(inner)
return None
[docs]
def layer_stack_to_gds_mapping(
layer_stack: LayerStack | None = None,
thickness_override: float | None = None,
) -> dict[int, tuple[float, float]]:
"""Convert a LayerStack to HFSS/Q3D GDS import mapping dictionary.
Vacuum levels (``material == "vacuum"``) are skipped: the AEDT background
region is already vacuum and any vacuum box is added explicitly with
:meth:`qpdk.simulation.hfss.HFSS.add_air_region`, so importing vacuum as
GDS geometry is redundant. This also resolves the Substrate/Vacuum
collision on ``LAYER.SIM_AREA`` (98, 0).
pyaedt's ``import_gds_3d`` keys the mapping on GDS layer number only — the
GDS datatype is not part of the key — so two levels sharing a layer number
(e.g. ``LAYER.AB_DRAW`` = (10, 0) and ``LAYER.AB_VIA`` = (10, 1)) are merged
into a single entry when they share a material and their z-spans join into
one box; otherwise an error is raised rather than silently importing wrong
geometry. Levels sharing a layer number are processed in ascending
``zmin`` order so the result does not depend on the layer stack's
insertion order.
Returns:
Dictionary mapping layer number to (elevation, thickness) tuple.
Raises:
ValueError: If two levels share a GDS layer number but cannot be merged
into a single (elevation, thickness) entry because their materials
differ (regardless of ``thickness_override``) or, with
``thickness_override=None``, their z-spans do not join into a
single box.
"""
if layer_stack is None:
layer_stack = LAYER_STACK
# Collect levels per layer number first, then process each number's levels
# in zmin order so the result is independent of the layer stack's
# insertion order.
levels_by_number: dict[int, list[tuple[str, LayerLevel, float, float]]] = {}
for name, layer_level in layer_stack.layers.items():
if layer_level.material == "vacuum":
logger.info(
f"Skipping layer level {name!r} in GDS import mapping: vacuum is"
" already the AEDT background region."
)
continue
layer_number = _get_layer_number_from_level(layer_level)
if layer_number is None:
continue
elevation = layer_level.zmin if layer_level.zmin is not None else 0.0
thickness = (
thickness_override
if thickness_override is not None
else (layer_level.thickness or 0.0)
)
levels_by_number.setdefault(layer_number, []).append((
name,
layer_level,
elevation,
thickness,
))
mapping: dict[int, tuple[float, float]] = {}
for layer_number, levels in levels_by_number.items():
levels.sort(key=itemgetter(2)) # ascending zmin
prev_name, prev_level, prev_elevation, prev_thickness = levels[0]
mapping[layer_number] = (prev_elevation, prev_thickness)
for name, layer_level, elevation, thickness in levels[1:]:
if prev_level.material != layer_level.material:
raise ValueError(
f"Layer levels {prev_name!r} ({prev_level.material!r}) and"
f" {name!r} ({layer_level.material!r}) share GDS layer"
f" {layer_number} but have different materials. pyaedt"
f" import_gds_3d keys the mapping on GDS layer number only"
f" (the datatype is discarded), so both would be imported as"
f" one object with a single material. Give them distinct GDS"
f" layer numbers or pass a custom layer_stack to"
f" import_component."
)
# Union of the two z-spans (um)
span_low = min(prev_elevation, elevation)
span_high = max(prev_elevation + prev_thickness, elevation + thickness)
# Overlapping or touching intervals join into a single box
max_low = max(prev_elevation, elevation)
min_high = min(prev_elevation + prev_thickness, elevation + thickness)
if thickness_override is not None:
# Sheets/forced thickness: colliding levels cannot keep distinct
# z-spans, so collapse to the lowest elevation and warn loudly.
logger.warning(
f"Layer levels {prev_name!r} and {name!r} share GDS layer"
f" {layer_number} (pyaedt import_gds_3d keys on layer number"
f" only): collapsing to a single entry at elevation"
f" {span_low} um; the higher level's geometry is not"
f" represented separately."
)
mapping[layer_number] = (span_low, thickness)
elif max_low > min_high + 1e-6: # 1 pm tolerance in um
raise ValueError(
f"Layer levels {prev_name!r} and {name!r} share GDS layer"
f" {layer_number} but their z-spans [{prev_elevation},"
f" {prev_elevation + prev_thickness}] and [{elevation},"
f" {elevation + thickness}] do not join into a single box."
f" pyaedt import_gds_3d keys the mapping on GDS layer number"
f" only (the datatype is discarded), so both would be imported"
f" with a single elevation and thickness. Give them distinct"
f" GDS layer numbers or pass a custom layer_stack to"
f" import_component."
)
else:
logger.info(
f"Merging layer levels {prev_name!r} and {name!r}: both use"
f" GDS layer {layer_number} and their z-spans join into a"
f" single box (elevation {span_low} um, thickness"
f" {span_high - span_low} um)."
)
mapping[layer_number] = (span_low, span_high - span_low)
prev_name = name
prev_level = layer_level
prev_elevation, prev_thickness = mapping[layer_number]
return mapping
[docs]
def prepare_component_for_aedt(
component: Component,
margin_draw: float = 0.0,
margin_etch: float = 0.0,
*,
name: str | None = None,
) -> Component:
"""Prepare a component for AEDT simulation export.
Thin backward-compatible wrapper around
:func:`qpdk.simulation.layout.prepare_metal_layout`, keeping the historical
``<component>_aedt`` name for the initial preparation cell.
Returns:
A copy of the component prepared for simulation.
"""
return prepare_metal_layout(
component,
margin_draw=margin_draw,
margin_etch=margin_etch,
name=name or f"{component.name}_aedt",
)
[docs]
@contextmanager
def export_component_to_gds_temp(
component: gf.Component,
gds_path: str | Path | None = None,
prefix: str = "qpdk_aedt_",
) -> Generator[Path, None, None]:
"""Context manager for exporting a component to a temporary GDS file.
The GDS is written without the layout's context info, where gdsfactory keeps its
ports, cross-sections and settings. AEDT needs the geometry only, and on AEDT
2026 R1 the importer fails on a file that carries the info: the import returns
success, the AEDT process is gone moments later, and every later call in the
session reports a dead desktop. A consumer that reads the file back with
gdsfactory rather than handing it to AEDT gets no ports from it.
Yields:
Path to the exported GDS file.
"""
if gds_path is not None:
path = Path(gds_path)
component.write_gds(str(path), with_metadata=False)
yield path
else:
with tempfile.TemporaryDirectory(prefix=prefix) as temp_dir:
path = Path(temp_dir) / "component.gds"
component.write_gds(str(path), with_metadata=False)
yield path
def _first_real_value(solution: Any) -> float | None:
"""Read the first real value of a solution's active expression.
``get_expression_data`` is awkward about missing data: an expression the report
does not carry comes back as empty arrays, and a sweep with no row matching the
requested variation returns ``None`` for both the values and the sweep, in both
cases while the ``SolutionData`` object itself stays truthy.
Args:
solution: A PyAEDT ``SolutionData`` for a single expression.
Returns:
The value, or ``None`` when the expression has no data to read.
"""
_, values = solution.get_expression_data(formula="real")
if values is None or len(values) == 0:
return None
return float(values[0])
[docs]
def rename_imported_objects(
app: Any,
new_objects: list[str],
layer_stack: LayerStack,
) -> list[str]:
"""Rename imported GDS objects based on the layer stack.
Returns:
List of renamed object names.
"""
num_to_name = {}
for name, level in layer_stack.layers.items():
layer_num = _get_layer_number_from_level(level)
if layer_num is not None and layer_num not in num_to_name:
num_to_name[layer_num] = name
renamed_objects = []
for obj_name in new_objects:
match = re.match(r"^signal(\d+)(_.*)?$", obj_name)
new_name = obj_name
if match:
layer_num = int(match.group(1))
suffix = match.group(2) or ""
if layer_num in num_to_name:
layer_name = num_to_name[layer_num]
new_name = f"{layer_name}{suffix}"
try:
app.modeler[obj_name].name = new_name
except Exception:
new_name = obj_name
renamed_objects.append(new_name)
return renamed_objects
[docs]
def object_names_to_materials(
object_names: list[str],
layer_stack: LayerStack,
) -> dict[str, str]:
"""Map imported object names to AEDT material names based on the layer stack.
Resolves each object to its :class:`LayerLevel` either by parsing the
``signal<layer_number>`` GDS import names (same convention as
:func:`rename_imported_objects`) or by matching the (renamed) object name
against layer stack names. Metal levels (materials with infinite
relative permittivity in ``material_properties``) map to ``"pec"``;
dielectric levels map to their layer stack material name.
Returns:
Dictionary mapping object names to AEDT material names.
Raises:
ValueError: If an object cannot be resolved to a layer stack level,
or its level material is not registered in ``material_properties``.
Failing closed avoids silently leaving objects with the project
default material, which would make extracted results wrong
rather than failed.
"""
num_to_name: dict[int, str] = {}
for name, level in layer_stack.layers.items():
layer_num = _get_layer_number_from_level(level)
if layer_num is not None and layer_num not in num_to_name:
num_to_name[layer_num] = name
def find_level(obj_name: str) -> LayerLevel | None:
match = re.match(r"^signal(\d+)(_.*)?$", obj_name)
if match and int(match.group(1)) in num_to_name:
return layer_stack.layers[num_to_name[int(match.group(1))]]
# Exact name first, then prefix match (longest names first so that
# e.g. "Airbridge_Via_1" is not matched by "Airbridge")
for name in sorted(layer_stack.layers, key=len, reverse=True):
if obj_name == name or obj_name.startswith(f"{name}_"):
return layer_stack.layers[name]
return None
assignments: dict[str, str] = {}
for obj_name in object_names:
level = find_level(obj_name)
material = level.material if level is not None else None
if material is None:
raise ValueError(
f"Could not resolve AEDT object {obj_name!r} to a layer stack "
"level with a material; refusing to leave it with the default "
"material."
)
props = material_properties.get(material)
if props is None:
raise ValueError(
f"Material {material!r} of AEDT object {obj_name!r} is not "
"registered in material_properties; refusing to leave it with "
"the default material."
)
is_conductor = props.get("relative_permittivity") == float("inf")
assignments[obj_name] = "pec" if is_conductor else material
return assignments
[docs]
def add_materials_to_aedt(app: Hfss | Q2d | Q3d) -> None:
"""Add QPDK materials to the PyAEDT application."""
for name, props in material_properties.items():
if app.materials.exists_material(name):
continue
mat = app.materials.add_material(name)
for prop_name, prop_value in props.items():
if prop_value == float("inf"):
if prop_name == "relative_permittivity":
mat.conductivity = 1e30
continue
if prop_name == "relative_permittivity":
mat.permittivity = prop_value
elif prop_name == "conductivity":
mat.conductivity = prop_value
[docs]
class AEDTBase(metaclass=SingletonMeta):
"""Base class for AEDT simulations."""
def __init__(self, app: Hfss | Q2d | Q3d):
"""Initialize the AEDT base class.
Args:
app: The PyAEDT application instance.
"""
self.app = app
@property
def modeler(self):
"""The AEDT modeler instance."""
return self.app.modeler
[docs]
def add_materials(self) -> None:
"""Add QPDK materials to the AEDT project."""
add_materials_to_aedt(self.app)
[docs]
def add_substrate(
self,
component: Component,
thickness: float = 500.0,
material: str = "silicon",
name: str = "Substrate",
) -> str:
"""Add a substrate box below the component geometry.
Returns:
Name of the created substrate 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
substrate = self.modeler.create_box(
origin=[x_min, y_min, -thickness],
sizes=[dx, dy, thickness],
name=name,
material=material,
)
substrate.mesh_order = 4
return substrate.name
[docs]
def save(self) -> None:
"""Save the AEDT project."""
self.app.save_project()
[docs]
def fit_view(app: Any) -> None:
"""Frame the model in a graphical AEDT session, and do nothing when headless.
``modeler.fit_all`` is a view operation. A non-graphical session has no view, and
on AEDT 2026 R1 the failed call loses the gRPC channel: the geometry is fine, but
every later call in the session reports a dead desktop.
The guard reads the flag the session was constructed with, not what the session
itself reports, so attaching to an existing headless session still needs
``non_graphical`` set or ``PYAEDT_NON_GRAPHICAL`` exported. A released session has
no desktop left and raises.
Args:
app: The AEDT application (an ``Hfss``, ``Q3d`` or ``Q2d`` instance).
"""
if not app.desktop_class.non_graphical:
app.modeler.fit_all()
[docs]
def detach_desktop_logging(app: Any) -> None:
"""Stop PyAEDT reading the AEDT desktop on every log message.
``Logger._log_on_desktop`` tests the desktop before it tests
``settings.enable_desktop_logs``, so the setting PyAEDT turns off itself in
non-graphical mode does not prevent the read. The read goes through
``Desktop.odesktop``, whose failure path releases the gRPC plugin's AEDT handle,
so a stray log message can end a session that AEDT is still running. Dropping the
logger's reference to the desktop is what ``Desktop.release_desktop`` does at the
end of a session anyway.
PyAEDT has one logger per process and every desktop construction points it back at
that desktop, so this is process-global and needs calling after each ``Hfss``,
``Q3d`` or ``Q2d`` is built. Messages the logger reads out of the desktop, such as
``get_messages(aedt_messages=True)``, stop working afterwards.
Args:
app: The AEDT application (an ``Hfss``, ``Q3d`` or ``Q2d`` instance).
"""
if hasattr(app.logger, "_desktop_class"):
app.logger._desktop_class = None
else: # pragma: no cover - only where PyAEDT renamed the attribute
logger.warning(
"PyAEDT's logger has no _desktop_class, so it keeps reading the desktop on"
" every log message. Check the PyAEDT version."
)