Types

PortType

Bases: Enum

Palace port types (maps to Palace config).

MeshPreset

Bases: Enum

Mesh quality presets based on COMSOL guidelines.

COMSOL uses 2nd order elements with ~5 elements per wavelength as default. Wavelength in dielectric: λ = c / (f * √εᵣ) At 100 GHz in SiO2 (εᵣ≈4): λ ≈ 1500 µm

GroundPlane dataclass

GroundPlane(layer_name: str, oversize: float = 50.0)

Ground plane configuration for microstrip structures.

Classes

Layer

Bases: BaseModel

Layer information for Palace simulation.

Methods:

Name Description
get_mesh_size

Get mesh size in um for this layer.

to_dict

Convert to dictionary for YAML output.

get_mesh_size

get_mesh_size(base_size: float = 1.0) -> float

Get mesh size in um for this layer.

Parameters:

Name Type Description Default
base_size float

Base mesh size for "medium" resolution

1.0

Returns:

Type Description
float

Mesh size in um

Source code in src/gsim/common/stack/extractor.py
def get_mesh_size(self, base_size: float = 1.0) -> float:
    """Get mesh size in um for this layer.

    Args:
        base_size: Base mesh size for "medium" resolution

    Returns:
        Mesh size in um
    """
    if isinstance(self.mesh_resolution, int | float):
        return float(self.mesh_resolution)

    resolution_map = {
        "fine": base_size * 0.5,
        "medium": base_size,
        "coarse": base_size * 2.0,
    }
    return resolution_map.get(str(self.mesh_resolution), base_size)

to_dict

to_dict() -> dict

Convert to dictionary for YAML output.

Source code in src/gsim/common/stack/extractor.py
def to_dict(self) -> dict:
    """Convert to dictionary for YAML output."""
    return {
        "gds_layer": list(self.gds_layer),
        "zmin": round(self.zmin, 4),
        "zmax": round(self.zmax, 4),
        "thickness": round(self.thickness, 4),
        "material": self.material,
        "type": self.layer_type,
        "mesh_resolution": self.mesh_resolution,
    }

LayerStack

Bases: BaseModel

Complete layer stack for Palace simulation.

Methods:

Name Description
get_conductor_layers

Get all conductor layers.

get_via_layers

Get all via layers.

get_z_range

Get the full z-range of the stack (substrate bottom to air top).

to_dict

Convert to dictionary for YAML output.

to_yaml

Convert to YAML string and optionally write to file.

validate_stack

Validate the layer stack for simulation readiness.

get_conductor_layers

get_conductor_layers() -> dict[str, Layer]

Get all conductor layers.

Source code in src/gsim/common/stack/extractor.py
def get_conductor_layers(self) -> dict[str, Layer]:
    """Get all conductor layers."""
    return {
        n: layer
        for n, layer in self.layers.items()
        if layer.layer_type == "conductor"
    }

get_via_layers

get_via_layers() -> dict[str, Layer]

Get all via layers.

Source code in src/gsim/common/stack/extractor.py
def get_via_layers(self) -> dict[str, Layer]:
    """Get all via layers."""
    return {
        n: layer for n, layer in self.layers.items() if layer.layer_type == "via"
    }

get_z_range

get_z_range() -> tuple[float, float]

Get the full z-range of the stack (substrate bottom to air top).

Source code in src/gsim/common/stack/extractor.py
def get_z_range(self) -> tuple[float, float]:
    """Get the full z-range of the stack (substrate bottom to air top)."""
    if not self.dielectrics:
        return (0.0, 0.0)
    z_min = min(d["zmin"] for d in self.dielectrics)
    z_max = max(d["zmax"] for d in self.dielectrics)
    return (z_min, z_max)

to_dict

to_dict() -> dict

Convert to dictionary for YAML output.

Source code in src/gsim/common/stack/extractor.py
def to_dict(self) -> dict:
    """Convert to dictionary for YAML output."""
    return {
        "version": "1.0",
        "pdk": self.pdk_name,
        "units": self.units,
        "materials": self.materials,
        "layers": {name: layer.to_dict() for name, layer in self.layers.items()},
        "dielectrics": self.dielectrics,
        "simulation": self.simulation,
    }

to_yaml

to_yaml(path: Path | None = None) -> str

Convert to YAML string and optionally write to file.

Parameters:

Name Type Description Default
path Path | None

Optional path to write YAML file

None

Returns:

Type Description
str

YAML string

Source code in src/gsim/common/stack/extractor.py
def to_yaml(self, path: Path | None = None) -> str:
    """Convert to YAML string and optionally write to file.

    Args:
        path: Optional path to write YAML file

    Returns:
        YAML string
    """
    yaml_str = yaml.dump(
        self.to_dict(),
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
    )

    if path:
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(yaml_str)
        logger.info("Stack written to: %s", path)

    return yaml_str

validate_stack

validate_stack(tolerance: float = 0.001) -> ValidationResult

Validate the layer stack for simulation readiness.

Checks: 1. Z-axis continuity: no gaps in dielectric regions 2. Material coverage: all materials have properties defined 3. Layer coverage: all conductor/via layers are within dielectric envelope 4. No negative thicknesses

Parameters:

Name Type Description Default
tolerance float

Tolerance for z-coordinate comparisons (um)

0.001

Returns:

Type Description
ValidationResult

ValidationResult with valid flag, errors, and warnings

Source code in src/gsim/common/stack/extractor.py
def validate_stack(self, tolerance: float = 0.001) -> ValidationResult:
    """Validate the layer stack for simulation readiness.

    Checks:
    1. Z-axis continuity: no gaps in dielectric regions
    2. Material coverage: all materials have properties defined
    3. Layer coverage: all conductor/via layers are within dielectric envelope
    4. No negative thicknesses

    Args:
        tolerance: Tolerance for z-coordinate comparisons (um)

    Returns:
        ValidationResult with valid flag, errors, and warnings
    """
    errors = []
    warnings = []

    # 1. Check all materials have required properties
    materials_used = set()

    # Collect materials from layers
    for name, layer in self.layers.items():
        materials_used.add(layer.material)
        if layer.thickness < 0:
            errors.append(
                f"Layer '{name}' has negative thickness: {layer.thickness}"
            )
        if layer.thickness == 0:
            warnings.append(f"Layer '{name}' has zero thickness")

    # Collect materials from dielectrics
    for d in self.dielectrics:
        materials_used.add(d["material"])

    # Check each material has properties
    for mat in materials_used:
        if mat not in self.materials:
            errors.append(
                f"Material '{mat}' used but not defined in materials dict"
            )
        else:
            props = self.materials[mat]
            mat_type = props.get("type", "unknown")
            if mat_type == "unknown":
                warnings.append(f"Material '{mat}' has unknown type")
            elif mat_type == "conductor":
                if "conductivity" not in props:
                    errors.append(
                        f"Conductor material '{mat}' missing conductivity"
                    )
            elif mat_type == "dielectric" and "permittivity" not in props:
                errors.append(f"Dielectric material '{mat}' missing permittivity")

    # 2. Check z-axis continuity of dielectrics
    if self.dielectrics:
        sorted_dielectrics = sorted(self.dielectrics, key=lambda d: d["zmin"])

        for i in range(len(sorted_dielectrics) - 1):
            current = sorted_dielectrics[i]
            next_d = sorted_dielectrics[i + 1]

            gap = next_d["zmin"] - current["zmax"]
            if gap > tolerance:
                errors.append(
                    f"Z-axis gap between '{current['name']}' "
                    f"(zmax={current['zmax']:.4f}) and '{next_d['name']}' "
                    f"(zmin={next_d['zmin']:.4f}): gap={gap:.4f} um"
                )
            elif gap < -tolerance:
                warnings.append(
                    f"Z-axis overlap between '{current['name']}' and "
                    f"'{next_d['name']}': overlap={-gap:.4f} um"
                )

        z_min_dielectric = sorted_dielectrics[0]["zmin"]
        z_max_dielectric = sorted_dielectrics[-1]["zmax"]
    else:
        errors.append("No dielectric regions defined")
        z_min_dielectric = 0
        z_max_dielectric = 0

    # 3. Check all conductor/via layers are within dielectric envelope
    for name, layer in self.layers.items():
        if layer.layer_type in ("conductor", "via"):
            if layer.zmin < z_min_dielectric - tolerance:
                errors.append(
                    f"Layer '{name}' extends below dielectric envelope: "
                    f"layer zmin={layer.zmin:.4f}, dielectric "
                    f"zmin={z_min_dielectric:.4f}"
                )
            if layer.zmax > z_max_dielectric + tolerance:
                errors.append(
                    f"Layer '{name}' extends above dielectric envelope: "
                    f"layer zmax={layer.zmax:.4f}, dielectric "
                    f"zmax={z_max_dielectric:.4f}"
                )

    # 4. Check we have at least substrate, oxide, and air
    dielectric_names = {d["name"] for d in self.dielectrics}
    if "substrate" not in dielectric_names:
        warnings.append("No 'substrate' dielectric region defined")
    if "air_box" not in dielectric_names:
        warnings.append("No 'air_box' dielectric region defined")

    valid = len(errors) == 0
    return ValidationResult(valid=valid, errors=errors, warnings=warnings)

StackLayer dataclass

StackLayer(
    name: str,
    zmin: float,
    zmax: float,
    thickness: float,
    material: str | None = None,
    gds_layer: int | None = None,
    layer_type: str = "conductor",
)

Parsed layer info for visualization.

MaterialProperties

Bases: BaseModel

EM properties for a material.

Methods:

Name Description
conductor

Create a conductor material.

dielectric

Create a dielectric material.

to_dict

Convert to dictionary for YAML output.

conductor classmethod

conductor(conductivity: float = 58000000.0) -> 'MaterialProperties'

Create a conductor material.

Source code in src/gsim/common/stack/materials.py
@classmethod
def conductor(cls, conductivity: float = 5.8e7) -> "MaterialProperties":
    """Create a conductor material."""
    return cls(type="conductor", conductivity=conductivity)

dielectric classmethod

dielectric(permittivity: float, loss_tangent: float = 0.0) -> 'MaterialProperties'

Create a dielectric material.

Source code in src/gsim/common/stack/materials.py
@classmethod
def dielectric(
    cls, permittivity: float, loss_tangent: float = 0.0
) -> "MaterialProperties":
    """Create a dielectric material."""
    return cls(
        type="dielectric", permittivity=permittivity, loss_tangent=loss_tangent
    )

to_dict

to_dict() -> dict[str, object]

Convert to dictionary for YAML output.

Source code in src/gsim/common/stack/materials.py
def to_dict(self) -> dict[str, object]:
    """Convert to dictionary for YAML output."""
    d: dict[str, object] = {"type": self.type}
    if self.conductivity is not None:
        d["conductivity"] = self.conductivity
    if self.permittivity is not None:
        d["permittivity"] = self.permittivity
    if self.loss_tangent is not None:
        d["loss_tangent"] = self.loss_tangent
    return d

ValidationResult

Bases: BaseModel

Result of simulation configuration validation.

Attributes:

Name Type Description
valid bool

Whether the configuration is valid

errors list[str]

List of error messages

warnings list[str]

List of warning messages

PalacePort dataclass

PalacePort(
    name: str,
    port_type: PortType = LUMPED,
    geometry: PortGeometry = INPLANE,
    center: tuple[float, float] = (0.0, 0.0),
    width: float = 0.0,
    orientation: float = 0.0,
    zmin: float = 0.0,
    zmax: float = 0.0,
    layer: str | None = None,
    from_layer: str | None = None,
    to_layer: str | None = None,
    length: float | None = None,
    multi_element: bool = False,
    centers: list[tuple[float, float]] | None = None,
    directions: list[str] | None = None,
    impedance: float = 50.0,
    excited: bool = True,
)

Port definition for Palace simulation.

Attributes:

Name Type Description
direction str

Get direction from orientation.

direction property

direction: str

Get direction from orientation.

PortGeometry

Bases: Enum

Internal geometry type for mesh generation.

MeshConfig dataclass

MeshConfig(
    refined_mesh_size: float = 5.0,
    max_mesh_size: float = 300.0,
    cells_per_wavelength: int = 10,
    margin: float = 50.0,
    air_above: float = 100.0,
    ground_plane: GroundPlane | None = None,
    fmax: float = 100000000000.0,
    boundary_conditions: list[str] | None = None,
    show_gui: bool = False,
    preview_only: bool = False,
)

Configuration for mesh generation.

Use class methods for quick presets

MeshConfig.coarse() - Fast iteration (~2.5 elem/λ) MeshConfig.default() - Balanced (COMSOL default, ~5 elem/λ) MeshConfig.fine() - High accuracy (~10 elem/λ)

Or customize directly

MeshConfig(refined_mesh_size=3.0, max_mesh_size=200.0)

Methods:

Name Description
coarse

Fast mesh for quick iteration (~2.5 elements per wavelength).

default

Balanced mesh matching COMSOL defaults (~5 elements per wavelength).

fine

High accuracy mesh (~10 elements per wavelength).

coarse classmethod

coarse(**kwargs) -> MeshConfig

Fast mesh for quick iteration (~2.5 elements per wavelength).

Source code in src/gsim/palace/mesh/pipeline.py
@classmethod
def coarse(cls, **kwargs) -> MeshConfig:
    """Fast mesh for quick iteration (~2.5 elements per wavelength)."""
    refined, max_size, cpw = _MESH_PRESETS[MeshPreset.COARSE]
    return cls(
        refined_mesh_size=refined,
        max_mesh_size=max_size,
        cells_per_wavelength=cpw,
        **kwargs,
    )

default classmethod

default(**kwargs) -> MeshConfig

Balanced mesh matching COMSOL defaults (~5 elements per wavelength).

Source code in src/gsim/palace/mesh/pipeline.py
@classmethod
def default(cls, **kwargs) -> MeshConfig:
    """Balanced mesh matching COMSOL defaults (~5 elements per wavelength)."""
    refined, max_size, cpw = _MESH_PRESETS[MeshPreset.DEFAULT]
    return cls(
        refined_mesh_size=refined,
        max_mesh_size=max_size,
        cells_per_wavelength=cpw,
        **kwargs,
    )

fine classmethod

fine(**kwargs) -> MeshConfig

High accuracy mesh (~10 elements per wavelength).

Source code in src/gsim/palace/mesh/pipeline.py
@classmethod
def fine(cls, **kwargs) -> MeshConfig:
    """High accuracy mesh (~10 elements per wavelength)."""
    refined, max_size, cpw = _MESH_PRESETS[MeshPreset.FINE]
    return cls(
        refined_mesh_size=refined,
        max_mesh_size=max_size,
        cells_per_wavelength=cpw,
        **kwargs,
    )

MeshResult dataclass

MeshResult(
    mesh_path: Path,
    config_path: Path | None = None,
    conductor_groups: dict = dict(),
    dielectric_groups: dict = dict(),
    port_groups: dict = dict(),
    boundary_groups: dict = dict(),
    port_info: list = list(),
    mesh_stats: dict = dict(),
    groups: dict = dict(),
    output_dir: Path | None = None,
    model_name: str = "palace",
    fmax: float = 100000000000.0,
)

Result from mesh generation.

Functions

get_stack

get_stack(yaml_path: str | Path | None = None, **kwargs) -> LayerStack

Get layer stack from active PDK or YAML file.

Parameters:

Name Type Description Default
yaml_path str | Path | None

Path to custom YAML stack file. If None, uses active PDK.

None
**kwargs

Additional args passed to extract_layer_stack: - substrate_thickness: Thickness below z=0 in um (default: 2.0) - air_above: Air box height above top metal in um (default: 200) - include_substrate: Include lossy silicon substrate (default: False). When False, matches gds2palace "nosub" behavior for RF simulation.

{}

Returns:

Type Description
LayerStack

LayerStack object

Examples:

stack = get_stack()

With lossy substrate (for substrate coupling studies)

stack = get_stack(include_substrate=True)

From YAML file

stack = get_stack(yaml_path="custom_stack.yaml")

With custom settings

stack = get_stack(air_above=300, substrate_thickness=5.0)

Source code in src/gsim/common/stack/__init__.py
def get_stack(
    yaml_path: str | Path | None = None,
    **kwargs,
) -> LayerStack:
    """Get layer stack from active PDK or YAML file.

    Args:
        yaml_path: Path to custom YAML stack file. If None, uses active PDK.
        **kwargs: Additional args passed to extract_layer_stack:
            - substrate_thickness: Thickness below z=0 in um (default: 2.0)
            - air_above: Air box height above top metal in um (default: 200)
            - include_substrate: Include lossy silicon substrate (default: False).
              When False, matches gds2palace "nosub" behavior for RF simulation.

    Returns:
        LayerStack object

    Examples:
        # From active PDK (after PDK.activate()) - no substrate (recommended for RF)
        stack = get_stack()

        # With lossy substrate (for substrate coupling studies)
        stack = get_stack(include_substrate=True)

        # From YAML file
        stack = get_stack(yaml_path="custom_stack.yaml")

        # With custom settings
        stack = get_stack(air_above=300, substrate_thickness=5.0)
    """
    if yaml_path is not None:
        return load_stack_yaml(yaml_path)

    pdk = gf.get_active_pdk()
    if pdk is None:
        raise ValueError("No active PDK found. Call PDK.activate() first.")

    return extract_from_pdk(pdk, **kwargs)

load_stack_yaml

load_stack_yaml(yaml_path: str | Path) -> LayerStack

Load layer stack from YAML file.

Parameters:

Name Type Description Default
yaml_path str | Path

Path to YAML file

required

Returns:

Type Description
LayerStack

LayerStack object

Source code in src/gsim/common/stack/__init__.py
def load_stack_yaml(yaml_path: str | Path) -> LayerStack:
    """Load layer stack from YAML file.

    Args:
        yaml_path: Path to YAML file

    Returns:
        LayerStack object
    """
    yaml_path = Path(yaml_path)
    with open(yaml_path) as f:
        data = yaml.safe_load(f)

    # Reconstruct LayerStack from dict
    stack = LayerStack(
        pdk_name=data.get("pdk", "unknown"),
        units=data.get("units", "um"),
    )

    # Load materials
    stack.materials = data.get("materials", {})

    # Load layers
    for name, layer_data in data.get("layers", {}).items():
        stack.layers[name] = Layer(
            name=name,
            gds_layer=tuple(layer_data["gds_layer"]),
            zmin=layer_data["zmin"],
            zmax=layer_data["zmax"],
            thickness=layer_data.get(
                "thickness", layer_data["zmax"] - layer_data["zmin"]
            ),
            material=layer_data["material"],
            layer_type=layer_data["type"],
            mesh_resolution=layer_data.get("mesh_resolution", "medium"),
        )

    # Load dielectrics
    stack.dielectrics = data.get("dielectrics", [])

    # Load simulation settings
    stack.simulation = data.get("simulation", {})

    return stack

extract_from_pdk

extract_from_pdk(pdk_module, output_path: Path | None = None, **kwargs) -> LayerStack

Extract layer stack from a PDK module or PDK object.

Parameters:

Name Type Description Default
pdk_module

PDK module (e.g., ihp, sky130) or gdsfactory Pdk object

required
output_path Path | None

Optional path to write YAML file

None
**kwargs

Additional arguments passed to extract_layer_stack

{}

Returns:

Type Description
LayerStack

LayerStack object for Palace simulation

Source code in src/gsim/common/stack/extractor.py
def extract_from_pdk(
    pdk_module,
    output_path: Path | None = None,
    **kwargs,
) -> LayerStack:
    """Extract layer stack from a PDK module or PDK object.

    Args:
        pdk_module: PDK module (e.g., ihp, sky130) or gdsfactory Pdk object
        output_path: Optional path to write YAML file
        **kwargs: Additional arguments passed to extract_layer_stack

    Returns:
        LayerStack object for Palace simulation
    """
    pdk_name = "unknown"

    if hasattr(pdk_module, "name") and isinstance(pdk_module.name, str):
        pdk_name = pdk_module.name
    elif hasattr(pdk_module, "PDK") and hasattr(pdk_module.PDK, "name"):
        pdk_name = pdk_module.PDK.name
    elif hasattr(pdk_module, "__name__"):
        pdk_name = pdk_module.__name__

    gf_layer_stack = None

    if hasattr(pdk_module, "layer_stack") and pdk_module.layer_stack is not None:
        gf_layer_stack = pdk_module.layer_stack
    elif hasattr(pdk_module, "LAYER_STACK"):
        gf_layer_stack = pdk_module.LAYER_STACK
    elif hasattr(pdk_module, "get_layer_stack"):
        gf_layer_stack = pdk_module.get_layer_stack()
    elif hasattr(pdk_module, "PDK") and hasattr(pdk_module.PDK, "layer_stack"):
        gf_layer_stack = pdk_module.PDK.layer_stack

    if gf_layer_stack is None:
        raise ValueError(f"Could not find layer stack in PDK: {pdk_module}")

    stack = extract_layer_stack(gf_layer_stack, pdk_name=pdk_name, **kwargs)

    if output_path:
        stack.to_yaml(output_path)

    return stack

extract_layer_stack

extract_layer_stack(
    gf_layer_stack: LayerStack,
    pdk_name: str = "unknown",
    substrate_thickness: float = 2.0,
    air_above: float = 200.0,
    boundary_margin: float = 30.0,
    include_substrate: bool = False,
) -> LayerStack

Extract layer stack from a gdsfactory LayerStack.

Parameters:

Name Type Description Default
gf_layer_stack LayerStack

gdsfactory LayerStack object

required
pdk_name str

Name of the PDK (for documentation)

'unknown'
substrate_thickness float

Thickness of substrate in um (default: 2.0)

2.0
air_above float

Height of air box above top metal in um (default: 200)

200.0
boundary_margin float

Lateral margin from GDS bbox in um (default: 30)

30.0
include_substrate bool

Whether to include lossy substrate (default: False)

False

Returns:

Type Description
LayerStack

LayerStack object for Palace simulation

Source code in src/gsim/common/stack/extractor.py
def extract_layer_stack(
    gf_layer_stack: GfLayerStack,
    pdk_name: str = "unknown",
    substrate_thickness: float = 2.0,
    air_above: float = 200.0,
    boundary_margin: float = 30.0,
    include_substrate: bool = False,
) -> LayerStack:
    """Extract layer stack from a gdsfactory LayerStack.

    Args:
        gf_layer_stack: gdsfactory LayerStack object
        pdk_name: Name of the PDK (for documentation)
        substrate_thickness: Thickness of substrate in um (default: 2.0)
        air_above: Height of air box above top metal in um (default: 200)
        boundary_margin: Lateral margin from GDS bbox in um (default: 30)
        include_substrate: Whether to include lossy substrate (default: False)

    Returns:
        LayerStack object for Palace simulation
    """
    stack = LayerStack(pdk_name=pdk_name)

    z_min_overall = float("inf")
    z_max_overall = float("-inf")
    materials_used: set[str] = set()

    for layer_name, layer_level in gf_layer_stack.layers.items():
        zmin = layer_level.zmin if layer_level.zmin is not None else 0.0
        thickness = layer_level.thickness if layer_level.thickness is not None else 0.0
        zmax = zmin + thickness
        material = layer_level.material if layer_level.material else "unknown"
        gds_layer = _get_gds_layer_tuple(layer_level)
        layer_type = _classify_layer_type(layer_name, material)

        if layer_type == "substrate" and not include_substrate:
            continue

        layer = Layer(
            name=layer_name,
            gds_layer=gds_layer,
            zmin=zmin,
            zmax=zmax,
            thickness=thickness,
            material=material,
            layer_type=layer_type,
        )

        stack.layers[layer_name] = layer
        materials_used.add(material)

        if layer_type != "substrate":
            z_min_overall = min(z_min_overall, zmin)
            z_max_overall = max(z_max_overall, zmax)

    for material in materials_used:
        props = get_material_properties(material)
        if props:
            stack.materials[material] = props.to_dict()
        else:
            stack.materials[material] = {
                "type": "unknown",
                "note": "Material not in database, please add properties manually",
            }

    if "air" not in stack.materials:
        stack.materials["air"] = MATERIALS_DB["air"].to_dict()

    if "SiO2" not in stack.materials:
        stack.materials["SiO2"] = MATERIALS_DB["SiO2"].to_dict()

    if include_substrate:
        stack.dielectrics.append(
            {
                "name": "substrate",
                "zmin": -substrate_thickness,
                "zmax": 0.0,
                "material": "silicon",
            }
        )
        if "silicon" not in stack.materials:
            stack.materials["silicon"] = MATERIALS_DB["silicon"].to_dict()
        oxide_zmin = 0.0
    else:
        oxide_zmin = -substrate_thickness

    stack.dielectrics.append(
        {
            "name": "oxide",
            "zmin": oxide_zmin,
            "zmax": z_max_overall,
            "material": "SiO2",
        }
    )

    passive_thickness = 0.4
    stack.dielectrics.append(
        {
            "name": "passive",
            "zmin": z_max_overall,
            "zmax": z_max_overall + passive_thickness,
            "material": "passive",
        }
    )
    if "passive" not in stack.materials:
        stack.materials["passive"] = MATERIALS_DB["passive"].to_dict()

    stack.dielectrics.append(
        {
            "name": "air_box",
            "zmin": z_max_overall + passive_thickness,
            "zmax": z_max_overall + passive_thickness + air_above,
            "material": "air",
        }
    )

    stack.simulation = {
        "boundary_margin": boundary_margin,
        "air_above": air_above,
        "substrate_thickness": substrate_thickness,
        "include_substrate": include_substrate,
    }

    return stack

parse_layer_stack

parse_layer_stack(layer_stack: LayerStack) -> list[StackLayer]

Parse a gdsfactory LayerStack into a list of StackLayer objects.

Parameters:

Name Type Description Default
layer_stack LayerStack

gdsfactory LayerStack object

required

Returns:

Type Description
list[StackLayer]

List of StackLayer objects sorted by zmin (ascending)

Source code in src/gsim/common/stack/visualization.py
def parse_layer_stack(layer_stack: GfLayerStack) -> list[StackLayer]:
    """Parse a gdsfactory LayerStack into a list of StackLayer objects.

    Args:
        layer_stack: gdsfactory LayerStack object

    Returns:
        List of StackLayer objects sorted by zmin (ascending)
    """
    layers = []

    for name, level in layer_stack.layers.items():
        zmin = level.zmin if level.zmin is not None else 0.0
        thickness = level.thickness if level.thickness is not None else 0.0
        zmax = zmin + thickness
        material = level.material if level.material else None
        gds_layer = _get_gds_layer_number(level)
        layer_type = _classify_layer(name)

        layers.append(
            StackLayer(
                name=name,
                zmin=zmin,
                zmax=zmax,
                thickness=thickness,
                material=material,
                gds_layer=gds_layer,
                layer_type=layer_type,
            )
        )

    # Sort by zmin ascending
    layers.sort(key=lambda layer: layer.zmin)
    return layers

get_material_properties

get_material_properties(material_name: str) -> MaterialProperties | None

Look up material properties by name.

Parameters:

Name Type Description Default
material_name str

Material name from PDK (e.g., "aluminum", "tungsten")

required

Returns:

Type Description
MaterialProperties | None

MaterialProperties if found, None otherwise

Source code in src/gsim/common/stack/materials.py
def get_material_properties(material_name: str) -> MaterialProperties | None:
    """Look up material properties by name.

    Args:
        material_name: Material name from PDK (e.g., "aluminum", "tungsten")

    Returns:
        MaterialProperties if found, None otherwise
    """
    name_lower = material_name.lower().strip()

    # Check direct match
    if name_lower in MATERIALS_DB:
        return MATERIALS_DB[name_lower]

    # Check aliases
    if name_lower in MATERIAL_ALIASES:
        return MATERIALS_DB[MATERIAL_ALIASES[name_lower]]

    # Check case-insensitive match in DB
    for db_name, props in MATERIALS_DB.items():
        if db_name.lower() == name_lower:
            return props

    return None

material_is_conductor

material_is_conductor(material_name: str) -> bool

Check if a material is a conductor.

Source code in src/gsim/common/stack/materials.py
def material_is_conductor(material_name: str) -> bool:
    """Check if a material is a conductor."""
    props = get_material_properties(material_name)
    return props is not None and props.type == "conductor"

material_is_dielectric

material_is_dielectric(material_name: str) -> bool

Check if a material is a dielectric.

Source code in src/gsim/common/stack/materials.py
def material_is_dielectric(material_name: str) -> bool:
    """Check if a material is a dielectric."""
    props = get_material_properties(material_name)
    return props is not None and props.type == "dielectric"

plot_stack

plot_stack(pdk, width: float = 600, height: float = 800, to_scale: bool = False)

Create an interactive plotly visualization of the layer stack.

Parameters:

Name Type Description Default
pdk

A PDK module with LAYER_STACK, or a LayerStack directly

required
width float

Figure width in pixels

600
height float

Figure height in pixels

800
to_scale bool

If True, show actual z dimensions. If False (default), use fixed height for all layers for better visibility.

False

Returns:

Type Description

plotly Figure object (displays automatically in notebooks)

Examples:

import ihp

plot_stack(ihp)
Source code in src/gsim/common/stack/visualization.py
def plot_stack(pdk, width: float = 600, height: float = 800, to_scale: bool = False):
    """Create an interactive plotly visualization of the layer stack.

    Args:
        pdk: A PDK module with LAYER_STACK, or a LayerStack directly
        width: Figure width in pixels
        height: Figure height in pixels
        to_scale: If True, show actual z dimensions. If False (default),
                  use fixed height for all layers for better visibility.

    Returns:
        plotly Figure object (displays automatically in notebooks)

    Examples:
        ```python
        import ihp

        plot_stack(ihp)
        ```
    """
    # Extract LayerStack from PDK module if needed
    layer_stack = pdk.LAYER_STACK if hasattr(pdk, "LAYER_STACK") else pdk

    layers = parse_layer_stack(layer_stack)

    if not layers:
        fig = go.Figure()
        fig.add_annotation(text="No layers found", x=0.5, y=0.5, showarrow=False)
        return fig

    # Color scheme by layer type
    colors = {
        "conductor": "#4CAF50",  # Green for metals/actives
        "via": "#87CEEB",  # Sky blue for vias
        "substrate": "#D0D0D0",  # Light gray for substrate
        "dielectric": "#D0D0D0",  # Light gray for dielectrics
    }

    # Find overlapping groups
    overlap_groups = _find_overlap_groups(layers)

    # Calculate column assignments and positions for each layer
    layer_columns = {}  # layer.name -> (column_index, total_columns_in_group)
    for group in overlap_groups:
        for i, layer in enumerate(group):
            layer_columns[layer.name] = (i, len(group))

    # Sort layers by zmin for consistent ordering
    sorted_layers = sorted(layers, key=lambda layer: layer.zmin)

    # Calculate uniform positions (not to scale)
    # In uniform mode, simply stack all layers vertically
    uniform_height = 1.0
    uniform_positions = {}  # layer.name -> (y0, y1)
    current_y = 0
    for layer in sorted_layers:
        uniform_positions[layer.name] = (current_y, current_y + uniform_height)
        current_y += uniform_height

    fig = go.Figure()

    # Base box width and x-coordinates
    total_width = 4
    base_x0 = 0

    # Helper to calculate x-coordinates for a layer based on its column
    # (for to-scale view).
    def get_x_coords_scaled(layer_name):
        col_idx, num_cols = layer_columns[layer_name]
        col_width = total_width / num_cols
        x0 = base_x0 + col_idx * col_width
        x1 = x0 + col_width
        return x0, x1

    # Add shapes and traces for both views
    # We'll use visibility toggling with buttons
    for _i, layer in enumerate(sorted_layers):
        color = colors.get(layer.layer_type, "#CCCCCC")

        # In uniform view, use full width; in to-scale view, use columns
        if to_scale:
            x0, x1 = get_x_coords_scaled(layer.name)
        else:
            x0, x1 = base_x0, base_x0 + total_width

        # Uniform (not to scale) positions
        u_y0, u_y1 = uniform_positions[layer.name]

        # To-scale positions
        s_y0, s_y1 = layer.zmin, layer.zmax

        # Use initial positions based on to_scale parameter
        y0 = s_y0 if to_scale else u_y0
        y1 = s_y1 if to_scale else u_y1

        # Add rectangle shape
        fig.add_shape(
            type="rect",
            x0=x0,
            x1=x1,
            y0=y0,
            y1=y1,
            fillcolor=color,
            line=dict(color="black", width=1),
            layer="below",
            name=f"shape_{layer.name}",
        )

        # Add invisible scatter for hover info
        fig.add_trace(
            go.Scatter(
                x=[(x0 + x1) / 2],
                y=[(y0 + y1) / 2],
                mode="markers",
                marker=dict(size=20, opacity=0),
                hoverinfo="text",
                hovertext=(
                    f"<b>{layer.name}</b><br>"
                    f"GDS Layer: {layer.gds_layer or 'N/A'}<br>"
                    f"Type: {layer.layer_type}<br>"
                    f"Z: {layer.zmin:.2f} - {layer.zmax:.2f} µm<br>"
                    f"Thickness: {layer.thickness:.3f} µm<br>"
                    f"Material: {layer.material or 'N/A'}"
                ),
                showlegend=False,
                name=f"hover_{layer.name}",
            )
        )

        # Add layer label
        fig.add_annotation(
            x=(x0 + x1) / 2,
            y=(y0 + y1) / 2,
            text=f"<b>{layer.name}</b>",
            showarrow=False,
            font=dict(size=10),
            name=f"label_{layer.name}",
        )

        # Add GDS layer number on the right side of the box
        if layer.gds_layer is not None:
            fig.add_annotation(
                x=x1 - 0.1,
                y=(y0 + y1) / 2,
                text=f"{layer.gds_layer}",
                showarrow=False,
                font=dict(size=9, color="gray"),
                xanchor="right",
            )

    # Build button data for toggling between views
    def build_layout_update(use_scale):
        shapes = []
        annotations = []

        for layer in sorted_layers:
            color = colors.get(layer.layer_type, "#CCCCCC")

            # Uniform view: full width; To-scale view: columns for overlaps
            if use_scale:
                lx0, lx1 = get_x_coords_scaled(layer.name)
                y0, y1 = layer.zmin, layer.zmax
            else:
                lx0, lx1 = base_x0, base_x0 + total_width
                y0, y1 = uniform_positions[layer.name]

            shapes.append(
                dict(
                    type="rect",
                    x0=lx0,
                    x1=lx1,
                    y0=y0,
                    y1=y1,
                    fillcolor=color,
                    line=dict(color="black", width=1),
                    layer="below",
                )
            )

            annotations.append(
                dict(
                    x=(lx0 + lx1) / 2,
                    y=(y0 + y1) / 2,
                    text=f"<b>{layer.name}</b>",
                    showarrow=False,
                    font=dict(size=10),
                )
            )

            # Add GDS layer number on the right side
            if layer.gds_layer is not None:
                annotations.append(
                    dict(
                        x=lx1 - 0.1,
                        y=(y0 + y1) / 2,
                        text=f"{layer.gds_layer}",
                        showarrow=False,
                        font=dict(size=9, color="gray"),
                        xanchor="right",
                    )
                )

        y_title = "Z (µm)" if use_scale else "Layer (not to scale)"
        if use_scale:
            y_range = [
                min(layer.zmin for layer in sorted_layers) - 1,
                max(layer.zmax for layer in sorted_layers) + 1,
            ]
        else:
            y_range = [-0.5, len(sorted_layers) + 0.5]

        return dict(
            shapes=shapes,
            annotations=annotations,
            yaxis=dict(
                title=y_title,
                range=y_range,
                showgrid=False,
                zeroline=False,
                showticklabels=use_scale,
            ),
        )

    # Build scatter y-positions for each view
    def build_scatter_update(use_scale):
        updates = []
        for layer in sorted_layers:
            if use_scale:
                y0, y1 = layer.zmin, layer.zmax
            else:
                y0, y1 = uniform_positions[layer.name]
            updates.append([(y0 + y1) / 2])
        return updates

    uniform_scatter_y = build_scatter_update(False)
    scale_scatter_y = build_scatter_update(True)

    # Initial y-range
    if to_scale:
        y_range = [
            min(layer.zmin for layer in sorted_layers) - 1,
            max(layer.zmax for layer in sorted_layers) + 1,
        ]
    else:
        y_range = [-0.5, len(sorted_layers) + 0.5]

    # Layout with buttons
    fig.update_layout(
        title="Layer Stack",
        width=width,
        height=height,
        xaxis=dict(
            showgrid=False,
            zeroline=False,
            showticklabels=False,
            range=[-0.5, 4.5],
        ),
        yaxis=dict(
            title="Layer (not to scale)" if not to_scale else "Z (µm)",
            showgrid=False,
            zeroline=False,
            showticklabels=to_scale,
            range=y_range,
        ),
        plot_bgcolor="white",
        hoverlabel=dict(bgcolor="white"),
        updatemenus=[
            dict(
                type="buttons",
                direction="left",
                x=0.0,
                y=1.15,
                xanchor="left",
                yanchor="top",
                buttons=[
                    dict(
                        label="Uniform",
                        method="update",
                        args=[
                            {"y": uniform_scatter_y},
                            build_layout_update(False),
                        ],
                    ),
                    dict(
                        label="To Scale",
                        method="update",
                        args=[
                            {"y": scale_scatter_y},
                            build_layout_update(True),
                        ],
                    ),
                ],
            ),
        ],
    )

    return fig

print_stack

print_stack(pdk) -> str

Print an ASCII diagram of the layer stack.

Parameters:

Name Type Description Default
pdk

A PDK module with LAYER_STACK, or a LayerStack directly

required

Returns:

Type Description
str

The formatted string (also prints to stdout)

Examples:

import ihp

print_stack(ihp)
Source code in src/gsim/common/stack/visualization.py
def print_stack(pdk) -> str:
    """Print an ASCII diagram of the layer stack.

    Args:
        pdk: A PDK module with LAYER_STACK, or a LayerStack directly

    Returns:
        The formatted string (also prints to stdout)

    Examples:
        ```python
        import ihp

        print_stack(ihp)
        ```
    """
    # Extract LayerStack from PDK module if needed
    layer_stack = pdk.LAYER_STACK if hasattr(pdk, "LAYER_STACK") else pdk

    layers = parse_layer_stack(layer_stack)

    if not layers:
        return "No layers found in stack"

    # Separate layers by type
    substrate_layer = None
    active_layers = []  # active, poly, etc.
    metal_layers = []

    for layer in layers:
        if layer.layer_type == "substrate":
            substrate_layer = layer
        elif layer.name.lower() in ("active", "poly", "gatpoly"):
            active_layers.append(layer)
        else:
            metal_layers.append(layer)

    # Build the diagram
    lines = []
    width = 50
    box_width = width - 4

    # Title
    title = "Layer Stack"
    lines.append(f"  Z (um){title:^{width + 10}}")
    lines.append("  " + "─" * (width + 18))

    # Sort metal layers by zmax descending for top-down drawing
    metal_layers_sorted = sorted(
        metal_layers, key=lambda layer: layer.zmax, reverse=True
    )

    # Draw top border
    if metal_layers_sorted:
        first_layer = metal_layers_sorted[0]
        lines.append(f"{first_layer.zmax:7.2f}{'─' * box_width}┐")

    # Draw each metal layer from top to bottom
    for _i, layer in enumerate(metal_layers_sorted):
        display_name = _format_layer_name(layer.name)
        thickness_str = (
            f"{layer.thickness:.2f} um"
            if layer.thickness >= 0.01
            else f"{layer.thickness * 1000:.0f} nm"
        )
        layer_str = f"Layer {layer.gds_layer}" if layer.gds_layer else ""

        name_part = f"{display_name:^{box_width - 24}}"
        info_part = f"{thickness_str:>10}  {layer_str:<10}"
        content = f"{name_part}{info_part}"

        lines.append(f"{'':>8}{content:^{box_width}}│")
        lines.append(f"{layer.zmin:7.2f}{'─' * box_width}┤")

    # Dielectric/oxide region
    lines.append(f"{'':>8}{'(dielectric / oxide)':^{box_width}}│")

    # Active layers (active, poly)
    if active_layers:
        active_sorted = sorted(
            active_layers, key=lambda layer: layer.zmax, reverse=True
        )
        z_top = max(layer.zmax for layer in active_layers)
        third = box_width // 3
        tail = "─" * (box_width - 2 * third - 2)
        lines.append(f"{z_top:7.2f}{'─' * third}{'─' * third}{tail}┤")

        names = "   ".join(layer.name.capitalize() for layer in active_sorted[:2])
        gds_layers = ", ".join(
            str(layer.gds_layer) for layer in active_sorted[:2] if layer.gds_layer
        )
        content = f"{names}  ~{active_sorted[0].thickness:.1f} um  Layer {gds_layers}"
        lines.append(f"{'':>8}{content:^{box_width}}│")
        lines.append(f"{0.00:7.2f}{'─' * third}{'─' * third}{tail}┤")
    else:
        lines.append(f"{0.00:7.2f}{'─' * box_width}┤")

    # Substrate
    if substrate_layer:
        lines.append(f"{'':>8}{'':^{box_width}}│")
        lines.append(f"{'':>8}{'Substrate (Si)':^{box_width}}│")
        sub_thickness = f"{abs(substrate_layer.thickness):.0f} um"
        lines.append(f"{'':>8}{sub_thickness:^{box_width}}│")
        lines.append(f"{'':>8}{'':^{box_width}}│")
        lines.append(f"{substrate_layer.zmin:7.0f}{'─' * box_width}┘")
    else:
        lines.append(f"{'':>8}{'─' * box_width}┘")

    lines.append("  " + "─" * (width + 18))

    result = "\n".join(lines)
    return result

print_stack_table

print_stack_table(pdk) -> str

Print a table of layer information.

Parameters:

Name Type Description Default
pdk

A PDK module with LAYER_STACK, or a LayerStack directly

required

Returns:

Type Description
str

The formatted string (also prints to stdout)

Examples:

import ihp

print_stack_table(ihp)
Source code in src/gsim/common/stack/visualization.py
def print_stack_table(pdk) -> str:
    """Print a table of layer information.

    Args:
        pdk: A PDK module with LAYER_STACK, or a LayerStack directly

    Returns:
        The formatted string (also prints to stdout)

    Examples:
        ```python
        import ihp

        print_stack_table(ihp)
        ```
    """
    layer_stack = pdk.LAYER_STACK if hasattr(pdk, "LAYER_STACK") else pdk

    layers = parse_layer_stack(layer_stack)

    lines = []
    lines.append("\nLayer Stack Table")
    lines.append("=" * 80)
    lines.append(
        f"{'Layer':<15} {'GDS':<8} {'Type':<12} "
        f"{'Z-min':>10} {'Z-max':>10} {'Thick':>10} {'Material':<12}"
    )
    lines.append("-" * 80)

    # Sort by zmin descending (top to bottom)
    for layer in sorted(layers, key=lambda layer: layer.zmin, reverse=True):
        gds = str(layer.gds_layer) if layer.gds_layer else "-"
        material = layer.material or "-"
        lines.append(
            f"{layer.name:<15} {gds:<8} {layer.layer_type:<12} "
            f"{layer.zmin:>10.2f} {layer.zmax:>10.2f} "
            f"{layer.thickness:>10.2f} {material:<12}"
        )

    lines.append("=" * 80)

    result = "\n".join(lines)
    return result

configure_inplane_port

configure_inplane_port(
    ports, layer: str, length: float, impedance: float = 50.0, excited: bool = True
)

Configure gdsfactory port(s) as inplane (lumped) ports for Palace simulation.

Inplane ports are horizontal ports on a single metal layer, used for CPW gaps or similar structures where excitation occurs in the XY plane.

Parameters:

Name Type Description Default
ports

Single gdsfactory Port or iterable of Ports (e.g., c.ports)

required
layer str

Target conductor layer name (e.g., 'topmetal2')

required
length float

Port extent along direction in um (perpendicular to port width)

required
impedance float

Port impedance in Ohms (default: 50)

50.0
excited bool

Whether port is excited vs just measured (default: True)

True

Examples:

configure_inplane_port(c.ports["o1"], layer="topmetal2", length=5.0)
configure_inplane_port(c.ports, layer="topmetal2", length=5.0)  # all ports
Source code in src/gsim/palace/ports/config.py
def configure_inplane_port(
    ports,
    layer: str,
    length: float,
    impedance: float = 50.0,
    excited: bool = True,
):
    """Configure gdsfactory port(s) as inplane (lumped) ports for Palace simulation.

    Inplane ports are horizontal ports on a single metal layer, used for CPW gaps
    or similar structures where excitation occurs in the XY plane.

    Args:
        ports: Single gdsfactory Port or iterable of Ports (e.g., c.ports)
        layer: Target conductor layer name (e.g., 'topmetal2')
        length: Port extent along direction in um (perpendicular to port width)
        impedance: Port impedance in Ohms (default: 50)
        excited: Whether port is excited vs just measured (default: True)

    Examples:
        ```python
        configure_inplane_port(c.ports["o1"], layer="topmetal2", length=5.0)
        configure_inplane_port(c.ports, layer="topmetal2", length=5.0)  # all ports
        ```
    """
    # Handle single port or iterable
    port_list = [ports] if hasattr(ports, "info") else ports

    for port in port_list:
        port.info["palace_type"] = "lumped"
        port.info["layer"] = layer
        port.info["length"] = length
        port.info["impedance"] = impedance
        port.info["excited"] = excited

configure_via_port

configure_via_port(
    ports, from_layer: str, to_layer: str, impedance: float = 50.0, excited: bool = True
)

Configure gdsfactory port(s) as via (vertical) lumped ports.

Via ports are vertical lumped ports between two metal layers, used for microstrip feed structures where excitation occurs in the Z direction.

Parameters:

Name Type Description Default
ports

Single gdsfactory Port or iterable of Ports (e.g., c.ports)

required
from_layer str

Bottom conductor layer name (e.g., 'metal1')

required
to_layer str

Top conductor layer name (e.g., 'topmetal2')

required
impedance float

Port impedance in Ohms (default: 50)

50.0
excited bool

Whether port is excited vs just measured (default: True)

True

Examples:

configure_via_port(c.ports["o1"], from_layer="metal1", to_layer="topmetal2")
configure_via_port(
    c.ports, from_layer="metal1", to_layer="topmetal2"
)  # all ports
Source code in src/gsim/palace/ports/config.py
def configure_via_port(
    ports,
    from_layer: str,
    to_layer: str,
    impedance: float = 50.0,
    excited: bool = True,
):
    """Configure gdsfactory port(s) as via (vertical) lumped ports.

    Via ports are vertical lumped ports between two metal layers, used for microstrip
    feed structures where excitation occurs in the Z direction.

    Args:
        ports: Single gdsfactory Port or iterable of Ports (e.g., c.ports)
        from_layer: Bottom conductor layer name (e.g., 'metal1')
        to_layer: Top conductor layer name (e.g., 'topmetal2')
        impedance: Port impedance in Ohms (default: 50)
        excited: Whether port is excited vs just measured (default: True)

    Examples:
        ```python
        configure_via_port(c.ports["o1"], from_layer="metal1", to_layer="topmetal2")
        configure_via_port(
            c.ports, from_layer="metal1", to_layer="topmetal2"
        )  # all ports
        ```
    """
    # Handle single port or iterable
    port_list = [ports] if hasattr(ports, "info") else ports

    for port in port_list:
        port.info["palace_type"] = "lumped"
        port.info["from_layer"] = from_layer
        port.info["to_layer"] = to_layer
        port.info["impedance"] = impedance
        port.info["excited"] = excited

configure_cpw_port

configure_cpw_port(
    port_upper,
    port_lower,
    layer: str,
    length: float,
    impedance: float = 50.0,
    excited: bool = True,
    cpw_name: str | None = None,
)

Configure two gdsfactory ports as a CPW (multi-element) lumped port.

In CPW (Ground-Signal-Ground), E-fields are opposite in the two gaps. This function links two ports to form one multi-element lumped port that Palace will excite with proper CPW mode.

Parameters:

Name Type Description Default
port_upper

gdsfactory Port for upper gap (signal-to-ground2)

required
port_lower

gdsfactory Port for lower gap (ground1-to-signal)

required
layer str

Target conductor layer name (e.g., 'topmetal2')

required
length float

Port extent along direction (um)

required
impedance float

Port impedance in Ohms (default: 50)

50.0
excited bool

Whether port is excited (default: True)

True
cpw_name str | None

Optional name for the CPW port (default: uses port_lower.name)

None

Examples:

configure_cpw_port(
    port_upper=c.ports["gap_upper"],
    port_lower=c.ports["gap_lower"],
    layer="topmetal2",
    length=5.0,
)
Source code in src/gsim/palace/ports/config.py
def configure_cpw_port(
    port_upper,
    port_lower,
    layer: str,
    length: float,
    impedance: float = 50.0,
    excited: bool = True,
    cpw_name: str | None = None,
):
    """Configure two gdsfactory ports as a CPW (multi-element) lumped port.

    In CPW (Ground-Signal-Ground), E-fields are opposite in the two gaps.
    This function links two ports to form one multi-element lumped port
    that Palace will excite with proper CPW mode.

    Args:
        port_upper: gdsfactory Port for upper gap (signal-to-ground2)
        port_lower: gdsfactory Port for lower gap (ground1-to-signal)
        layer: Target conductor layer name (e.g., 'topmetal2')
        length: Port extent along direction (um)
        impedance: Port impedance in Ohms (default: 50)
        excited: Whether port is excited (default: True)
        cpw_name: Optional name for the CPW port (default: uses port_lower.name)

    Examples:
        ```python
        configure_cpw_port(
            port_upper=c.ports["gap_upper"],
            port_lower=c.ports["gap_lower"],
            layer="topmetal2",
            length=5.0,
        )
        ```
    """
    # Generate unique CPW group ID
    cpw_group_id = cpw_name or f"cpw_{port_lower.name}"

    # Auto-detect directions based on positions
    upper_y = float(port_upper.center[1])
    lower_y = float(port_lower.center[1])

    # The port farther from origin in Y gets negative direction (E toward signal)
    # The port closer to origin gets positive direction (E toward signal)
    if upper_y > lower_y:
        upper_direction = "-Y"
        lower_direction = "+Y"
    else:
        upper_direction = "+Y"
        lower_direction = "-Y"

    # Store metadata on BOTH ports, marking them as CPW elements
    for port, direction in [
        (port_upper, upper_direction),
        (port_lower, lower_direction),
    ]:
        port.info["palace_type"] = "cpw_element"
        port.info["cpw_group"] = cpw_group_id
        port.info["cpw_direction"] = direction
        port.info["layer"] = layer
        port.info["length"] = length
        port.info["impedance"] = impedance
        port.info["excited"] = excited

extract_ports

extract_ports(component, stack: LayerStack) -> list[PalacePort]

Extract Palace ports from a gdsfactory component.

Handles all port types: inplane, via, and CPW (multi-element). CPW ports are automatically grouped by their cpw_group ID.

Parameters:

Name Type Description Default
component

gdsfactory Component with configured ports

required
stack LayerStack

LayerStack from stack module

required

Returns:

Type Description
list[PalacePort]

List of PalacePort objects ready for simulation

Source code in src/gsim/palace/ports/config.py
def extract_ports(component, stack: LayerStack) -> list[PalacePort]:
    """Extract Palace ports from a gdsfactory component.

    Handles all port types: inplane, via, and CPW (multi-element).
    CPW ports are automatically grouped by their cpw_group ID.

    Args:
        component: gdsfactory Component with configured ports
        stack: LayerStack from stack module

    Returns:
        List of PalacePort objects ready for simulation
    """
    palace_ports = []

    # First, collect CPW elements grouped by cpw_group
    cpw_groups: dict[str, list] = {}

    for port in component.ports:
        info = port.info
        palace_type = info.get("palace_type")

        if palace_type is None:
            continue

        if palace_type == "cpw_element":
            group_id = info.get("cpw_group")
            if group_id:
                if group_id not in cpw_groups:
                    cpw_groups[group_id] = []
                cpw_groups[group_id].append(port)
            continue

        # Handle single-element ports (lumped, waveport)
        center = (float(port.center[0]), float(port.center[1]))
        width = float(port.width)
        orientation = float(port.orientation) if port.orientation is not None else 0.0

        zmin, zmax = 0.0, 0.0
        from_layer = info.get("from_layer")
        to_layer = info.get("to_layer")
        layer_name = info.get("layer")

        if palace_type == "lumped":
            port_type = PortType.LUMPED
            if from_layer and to_layer:
                geometry = PortGeometry.VIA
                if from_layer in stack.layers:
                    zmin = stack.layers[from_layer].zmin
                if to_layer in stack.layers:
                    zmax = stack.layers[to_layer].zmax
            elif layer_name:
                geometry = PortGeometry.INPLANE
                if layer_name in stack.layers:
                    layer = stack.layers[layer_name]
                    zmin = layer.zmin
                    zmax = layer.zmax
            else:
                raise ValueError(f"Lumped port '{port.name}' missing layer info")

        elif palace_type == "waveport":
            port_type = PortType.WAVEPORT
            geometry = PortGeometry.INPLANE  # Waveport geometry TBD
            zmin, zmax = stack.get_z_range()

        else:
            raise ValueError(f"Unknown port type: {palace_type}")

        palace_port = PalacePort(
            name=port.name,
            port_type=port_type,
            geometry=geometry,
            center=center,
            width=width,
            orientation=orientation,
            zmin=zmin,
            zmax=zmax,
            layer=layer_name,
            from_layer=from_layer,
            to_layer=to_layer,
            length=info.get("length"),
            impedance=info.get("impedance", 50.0),
            excited=info.get("excited", True),
        )
        palace_ports.append(palace_port)

    # Now process CPW groups into multi-element PalacePort objects
    for group_id, ports in cpw_groups.items():
        if len(ports) != 2:
            raise ValueError(
                f"CPW group '{group_id}' must have exactly 2 ports, got {len(ports)}"
            )

        # Sort by Y position to get consistent ordering
        ports_sorted = sorted(ports, key=lambda p: p.center[1], reverse=True)
        port_upper, port_lower = ports_sorted[0], ports_sorted[1]

        info = port_lower.info
        layer_name = info.get("layer")

        # Get z coordinates from stack
        zmin, zmax = 0.0, 0.0
        if layer_name and layer_name in stack.layers:
            layer = stack.layers[layer_name]
            zmin = layer.zmin
            zmax = layer.zmax

        # Get centers and directions
        centers = [
            (float(port_upper.center[0]), float(port_upper.center[1])),
            (float(port_lower.center[0]), float(port_lower.center[1])),
        ]
        directions = [
            port_upper.info.get("cpw_direction", "-Y"),
            port_lower.info.get("cpw_direction", "+Y"),
        ]

        # Use average center for the main center field
        avg_center = (
            (centers[0][0] + centers[1][0]) / 2,
            (centers[0][1] + centers[1][1]) / 2,
        )

        cpw_port = PalacePort(
            name=group_id,
            port_type=PortType.LUMPED,
            geometry=PortGeometry.INPLANE,
            center=avg_center,
            width=float(port_lower.width),
            orientation=float(port_lower.orientation)
            if port_lower.orientation
            else 0.0,
            zmin=zmin,
            zmax=zmax,
            layer=layer_name,
            length=info.get("length"),
            multi_element=True,
            centers=centers,
            directions=directions,
            impedance=info.get("impedance", 50.0),
            excited=info.get("excited", True),
        )
        palace_ports.append(cpw_port)

    return palace_ports

generate_mesh

generate_mesh(
    component,
    stack: LayerStack,
    ports: list[PalacePort],
    output_dir: str | Path,
    config: MeshConfig | None = None,
    model_name: str = "palace",
    driven_config: DrivenConfig | None = None,
    write_config: bool = True,
) -> MeshResult

Generate mesh for Palace EM simulation.

Parameters:

Name Type Description Default
component

gdsfactory Component

required
stack LayerStack

LayerStack from palace-api

required
ports list[PalacePort]

List of PalacePort objects (single and multi-element)

required
output_dir str | Path

Directory for output files

required
config MeshConfig | None

MeshConfig with mesh parameters

None
model_name str

Base name for output files (default: "mesh" -> mesh.msh)

'palace'
driven_config DrivenConfig | None

Optional DrivenConfig for frequency sweep settings

None
write_config bool

Whether to write config.json (default True)

True

Returns:

Type Description
MeshResult

MeshResult with mesh path and metadata

Source code in src/gsim/palace/mesh/pipeline.py
def generate_mesh(
    component,
    stack: LayerStack,
    ports: list[PalacePort],
    output_dir: str | Path,
    config: MeshConfig | None = None,
    model_name: str = "palace",
    driven_config: DrivenConfig | None = None,
    write_config: bool = True,
) -> MeshResult:
    """Generate mesh for Palace EM simulation.

    Args:
        component: gdsfactory Component
        stack: LayerStack from palace-api
        ports: List of PalacePort objects (single and multi-element)
        output_dir: Directory for output files
        config: MeshConfig with mesh parameters
        model_name: Base name for output files (default: "mesh" -> mesh.msh)
        driven_config: Optional DrivenConfig for frequency sweep settings
        write_config: Whether to write config.json (default True)

    Returns:
        MeshResult with mesh path and metadata
    """
    if config is None:
        config = MeshConfig()

    output_dir = Path(output_dir)

    # Use new generator
    result = gen_mesh(
        component=component,
        stack=stack,
        ports=ports,
        output_dir=output_dir,
        model_name=model_name,
        refined_mesh_size=config.refined_mesh_size,
        max_mesh_size=config.max_mesh_size,
        margin=config.margin,
        air_margin=config.margin,
        fmax=config.fmax,
        show_gui=config.show_gui,
        driven_config=driven_config,
        write_config=write_config,
    )

    # Convert to pipeline's MeshResult format
    return MeshResult(
        mesh_path=result.mesh_path,
        config_path=result.config_path,
        port_info=result.port_info,
        mesh_stats=result.mesh_stats,
        groups=result.groups,
        output_dir=result.output_dir,
        model_name=result.model_name,
        fmax=result.fmax,
    )

Constants

MATERIALS_DB module-attribute

MATERIALS_DB: dict[str, MaterialProperties] = {
    "aluminum": MaterialProperties(type="conductor", conductivity=37700000.0),
    "copper": MaterialProperties(type="conductor", conductivity=58000000.0),
    "tungsten": MaterialProperties(type="conductor", conductivity=18200000.0),
    "gold": MaterialProperties(type="conductor", conductivity=41000000.0),
    "TiN": MaterialProperties(type="conductor", conductivity=5000000.0),
    "poly_si": MaterialProperties(type="conductor", conductivity=100000.0),
    "SiO2": MaterialProperties(type="dielectric", permittivity=4.1, loss_tangent=0.0),
    "passive": MaterialProperties(
        type="dielectric", permittivity=6.6, loss_tangent=0.0
    ),
    "Si3N4": MaterialProperties(
        type="dielectric", permittivity=7.5, loss_tangent=0.001
    ),
    "polyimide": MaterialProperties(
        type="dielectric", permittivity=3.4, loss_tangent=0.002
    ),
    "air": MaterialProperties(type="dielectric", permittivity=1.0, loss_tangent=0.0),
    "vacuum": MaterialProperties(type="dielectric", permittivity=1.0, loss_tangent=0.0),
    "silicon": MaterialProperties(
        type="semiconductor", permittivity=11.9, conductivity=2.0
    ),
    "si": MaterialProperties(type="semiconductor", permittivity=11.9, conductivity=2.0),
}