Source code for qpdk.simulation.fem

"""Volumetric-FEM helpers for qpdk layouts.

Converts the PDK's subtractive etch masks into the explicit conductor and
dielectric regions that full-wave 3-D solvers (Palace through gsim, Meep, ...)
expect, and provides the matching layer stacks. Install gsim with the ``palace``
dependency group on Python 3.12. Functions that need it import it lazily.
"""

from __future__ import annotations

from collections.abc import Collection
from typing import Any, Literal

import gdsfactory as gf
import klayout.db as kdb
from kfactory.layer import LayerEnum

from qpdk.tech import CHIP_LAYERS, LAYER, NON_METADATA_LAYERS, Chip, material_properties

__all__ = [
    "FEM_LAYERS",
    "FLIP_CHIP_FEM_LAYERS",
    "TSV_FEM_LAYERS",
    "flip_chip_stack",
    "single_chip_stack",
    "to_fem_regions",
    "to_flip_chip_regions",
    "to_tsv_regions",
    "tsv_stack",
]

# Layer numbering understood by gsim's Palace and Meep workflows: dielectric
# substrate, zero-thickness conductor sheet, and the vacuum above the chip.
# The GDS numbers come from the unused simulation-only block, next to
# SIM_AREA (98,0) / SIM_ONLY (99,0): they must not collide with real mask
# layers (M1_DRAW is (1,0), M2_DRAW (2,0), ...), or the PDK's own
# additive-metal pass would destroy a written-back FEM component and any
# GDS round-trip would show dielectric as metal.
FEM_LAYERS = {"SUBSTRATE": (91, 0), "SUPERCONDUCTOR": (92, 0), "VACUUM": (93, 0)}


def _reject_unsupported_layers(
    component: gf.Component, supported: Collection[LayerEnum]
) -> None:
    """Reject fabrication masks that would be silently lost by conversion.

    Raises:
        ValueError: If an unsupported fabrication layer carries polygons.
    """
    unsupported = {
        tuple(layer): str(layer)
        for layer in NON_METADATA_LAYERS
        if layer not in supported
    }
    layout = component.kdb_cell.layout()
    present = []
    for index in layout.layer_indices():
        info = layout.get_info(index)
        spec = (info.layer, info.datatype)
        if (
            spec in unsupported
            and not kdb.Region(component.kdb_cell.begin_shapes_rec(index)).is_empty()
        ):
            present.append(unsupported[spec])
    if present:
        raise ValueError(
            f"FEM conversion does not model fabrication layers {sorted(present)}"
        )


[docs] def to_fem_regions(component: gf.Component) -> gf.Component: """Convert an etch-based qpdk layout into explicit FEM regions. The PDK draws metal subtractively: ``M1_ETCH`` marks where metal is removed and additive shapes on ``M1_DRAW`` punch through the etch mask (the conductor is ``SIM_AREA - (M1_ETCH - M1_DRAW)``), matching ``get_layer_stack()``'s M1 definition. The conductor, substrate and vacuum regions are copied onto the layer numbering of :data:`FEM_LAYERS`, with the zero-thickness conductor later becoming a perfect-electric-conductor boundary in the solver. Junction masks are omitted because lumped ports represent them. Other fabrication layers outside M1 are rejected. Args: component: Layout with a ``SIM_AREA`` layer and simulation ports. Returns: Component carrying the ``SUBSTRATE``, ``SUPERCONDUCTOR`` and ``VACUUM`` regions plus the input ports. Raises: ValueError: If unsupported fabrication layers are present, or the component carries no ``SIM_AREA`` shapes or the simulation area is fully etched away, either of which would silently produce an empty FEM model. """ _reject_unsupported_layers( component, {LAYER.M1_DRAW, LAYER.M1_ETCH, LAYER.JJ_AREA, LAYER.JJ_PATCH} ) layout = component.kdb_cell.layout() sim_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*LAYER.SIM_AREA)) ) etch_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*LAYER.M1_ETCH)) ) draw_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*LAYER.M1_DRAW)) ) conductor_region = sim_region - (etch_region - draw_region) if sim_region.is_empty(): msg = "no SIM_AREA layer in the component; the FEM model would be empty" raise ValueError(msg) if conductor_region.is_empty(): msg = "SIM_AREA is fully etched away; the FEM conductor would be empty" raise ValueError(msg) etched = gf.Component(kcl=component.kcl) el = etched.kdb_cell.layout() for name, region in [ ("SUPERCONDUCTOR", conductor_region), ("SUBSTRATE", sim_region), ("VACUUM", sim_region), ]: etched.kdb_cell.shapes(el.layer(*FEM_LAYERS[name])).insert(region) for port in component.ports: etched.add_port(name=port.name, port=port) return etched
[docs] def single_chip_stack( substrate_thickness: float = 500.0, vacuum_thickness: float = 500.0 ) -> Any: r"""Return a gsim layer stack for a single qpdk silicon chip. The substrate uses the microwave silicon from the qpdk material properties (:math:`\varepsilon_r = 11.45`, :math:`\tan\delta = 2.7 \times 10^{-6}`), so FEM and analytical CPW models describe the same chip. The zero-thickness niobium conductor becomes a perfect electric conductor in the solver; its material entry only names the layer. Args: substrate_thickness: Silicon thickness in μm. vacuum_thickness: Air height above the chip in μm. Returns: A ``gsim.common.stack.LayerStack`` (requires the ``palace`` dependency group on Python 3.12). """ from gsim.common.stack import Layer, LayerStack from gsim.common.stack.materials import MATERIALS_DB silicon = material_properties["Si"] stack = LayerStack(pdk_name="qpdk") stack.layers["SUBSTRATE"] = Layer( name="SUBSTRATE", gds_layer=FEM_LAYERS["SUBSTRATE"], zmin=0.0, zmax=substrate_thickness, thickness=substrate_thickness, material="qpdk-silicon", layer_type="dielectric", ) stack.layers["SUPERCONDUCTOR"] = Layer( name="SUPERCONDUCTOR", gds_layer=FEM_LAYERS["SUPERCONDUCTOR"], zmin=substrate_thickness, zmax=substrate_thickness, thickness=0, material="qpdk-niobium", layer_type="conductor", ) stack.layers["VACUUM"] = Layer( name="VACUUM", gds_layer=FEM_LAYERS["VACUUM"], zmin=substrate_thickness, zmax=substrate_thickness + vacuum_thickness, thickness=vacuum_thickness, material="vacuum", layer_type="dielectric", ) stack.materials = { "qpdk-silicon": { "permittivity": silicon["relative_permittivity"], "loss_tangent": silicon["loss_tangent"], }, "qpdk-niobium": {"permittivity": 1.0}, "vacuum": MATERIALS_DB["vacuum"].to_dict(), } return stack
# Layer numbering for two-chip (flip-chip) models: the bottom chip sits below # the inter-chip gap, the top chip is flipped so its metal faces down. Keep # these outside the mask and label layer numbers. FLIP_CHIP_FEM_LAYERS = { "SUBSTRATE": (114, 0), "M1": (115, 0), "VACUUM": (116, 0), "BUMP": (117, 0), "M2": (118, 0), "SUBSTRATE_TOP": (119, 0), }
[docs] def to_flip_chip_regions(component: gf.Component) -> gf.Component: """Convert a two-metal-layer qpdk layout into explicit flip-chip regions. Both metal levels follow the same subtractive convention as :func:`to_fem_regions` (conductor is ``SIM_AREA - (ETCH - DRAW)``, per ``LAYER_STACK_FLIP_CHIP``), and the indium bump layer is copied to its own region. The regions land on the layer numbering of :data:`FLIP_CHIP_FEM_LAYERS`: two substrate slabs, two zero-thickness metal sheets, the vacuum gap, and the bumps. Junction masks are omitted because lumped ports represent them. Other fabrication layers are rejected. Args: component: Flip-chip layout with a ``SIM_AREA`` layer, M1/M2 metal and ``IND`` bump shapes, plus simulation ports. Returns: Component carrying the flip-chip regions plus the input ports. Raises: ValueError: If unsupported fabrication layers are present, the component carries no ``SIM_AREA`` shapes, a metal level has neither etch nor draw shapes (which the subtractive convention would turn into a solid metal plane shorting the model), is fully etched away, or there are no indium bumps to connect the two chips. """ _reject_unsupported_layers( component, { LAYER.M1_DRAW, LAYER.M1_ETCH, LAYER.M2_DRAW, LAYER.M2_ETCH, LAYER.IND, LAYER.JJ_AREA, LAYER.JJ_PATCH, }, ) layout = component.kdb_cell.layout() sim_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*LAYER.SIM_AREA)) ) if sim_region.is_empty(): msg = "no SIM_AREA layer in the component; the FEM model would be empty" raise ValueError(msg) def _level_conductor( etch: tuple[int, int], draw: tuple[int, int], name: str ) -> kdb.Region: """Conductor = SIM_AREA - ETCH + DRAW for one metal level.""" etch_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*etch)) ) draw_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*draw)) ) if etch_region.is_empty() and draw_region.is_empty(): # A level with no etch and no draw shapes would degenerate to a # solid metal plane, silently shorting the model. msg = ( f"metal level {name} has neither etch nor draw shapes;" " the subtractive convention would make it a solid plane" ) raise ValueError(msg) conductor = ((sim_region - etch_region) | (draw_region & sim_region)).merged() if conductor.is_empty(): raise ValueError(f"metal level {name} is fully etched away") return conductor m1_region = _level_conductor(LAYER.M1_ETCH, LAYER.M1_DRAW, "M1") m2_region = _level_conductor(LAYER.M2_ETCH, LAYER.M2_DRAW, "M2") # Bumps outside the simulation area would poke into empty space and # grow the model bounding box, so clip them to it. bump_region = ( kdb.Region(component.kdb_cell.begin_shapes_rec(layout.layer(*LAYER.IND))) & sim_region ).merged() if bump_region.is_empty(): msg = "no indium bumps in the component; the two chips would not connect" raise ValueError(msg) regions = gf.Component(kcl=component.kcl) rl = regions.kdb_cell.layout() for name, region in [ ("SUBSTRATE", sim_region), ("M1", m1_region), ("VACUUM", sim_region), ("BUMP", bump_region), ("M2", m2_region), ("SUBSTRATE_TOP", sim_region), ]: regions.kdb_cell.shapes(rl.layer(*FLIP_CHIP_FEM_LAYERS[name])).insert(region) for port in component.ports: regions.add_port(name=port.name, port=port) return regions
[docs] def flip_chip_stack( substrate_thickness: float = 500.0, bump_thickness: float = 10.0, ) -> Any: """Return a gsim layer stack for a face-to-face qpdk flip-chip pair. The bottom chip carries M1 with the substrate below it; the top chip is flipped so its M2 faces down across the indium-bump gap, with its substrate above. Both metals become perfect electric conductors and the bumps conductive vias; the substrate uses the qpdk microwave silicon, so FEM and analytical models describe the same chips. Args: substrate_thickness: Silicon thickness of each chip in μm. bump_thickness: Gap height between the chips (bump height) in μm. Returns: A ``gsim.common.stack.LayerStack`` (requires the ``palace`` dependency group on Python 3.12). """ from gsim.common.stack import Layer, LayerStack from gsim.common.stack.materials import MATERIALS_DB silicon = material_properties["Si"] stack = LayerStack(pdk_name="qpdk") stack.layers["SUBSTRATE"] = Layer( name="SUBSTRATE", gds_layer=FLIP_CHIP_FEM_LAYERS["SUBSTRATE"], zmin=-substrate_thickness, zmax=0.0, thickness=substrate_thickness, material="qpdk-silicon", layer_type="dielectric", ) stack.layers["M1"] = Layer( name="M1", gds_layer=FLIP_CHIP_FEM_LAYERS["M1"], zmin=0.0, zmax=0.0, thickness=0, material="qpdk-niobium", layer_type="conductor", ) stack.layers["VACUUM"] = Layer( name="VACUUM", gds_layer=FLIP_CHIP_FEM_LAYERS["VACUUM"], zmin=0.0, zmax=bump_thickness, thickness=bump_thickness, material="vacuum", layer_type="dielectric", ) stack.layers["BUMP"] = Layer( name="BUMP", gds_layer=FLIP_CHIP_FEM_LAYERS["BUMP"], zmin=0.0, zmax=bump_thickness, thickness=bump_thickness, material="qpdk-indium", layer_type="via", ) stack.layers["M2"] = Layer( name="M2", gds_layer=FLIP_CHIP_FEM_LAYERS["M2"], zmin=bump_thickness, zmax=bump_thickness, thickness=0, material="qpdk-niobium", layer_type="conductor", ) stack.layers["SUBSTRATE_TOP"] = Layer( name="SUBSTRATE_TOP", gds_layer=FLIP_CHIP_FEM_LAYERS["SUBSTRATE_TOP"], zmin=bump_thickness, zmax=bump_thickness + substrate_thickness, thickness=substrate_thickness, material="qpdk-silicon", layer_type="dielectric", ) stack.materials = { "qpdk-silicon": { "permittivity": silicon["relative_permittivity"], "loss_tangent": silicon["loss_tangent"], }, "qpdk-niobium": {"permittivity": 1.0}, # Indium σ from literature; without a conductivity gsim degrades the # via to a 2-D PEC sheet at its base, which no longer bridges the # two conductor planes. "qpdk-indium": {"conductivity": 1.16e7}, "vacuum": MATERIALS_DB["vacuum"].to_dict(), } return stack
# Layer numbering for single-chip models with metal on both faces joined by # through-silicon vias: M1 on the front face, MB on the back face, and the # vias through the substrate between them. The numbers continue the # simulation-only block after FLIP_CHIP_FEM_LAYERS. For the flip-chip top chip # (``to_tsv_regions(..., chip="top")``) M1 holds its front metal M2 and MB its # backside metal MB2. TSV_FEM_LAYERS = { "VACUUM_BOTTOM": (108, 0), # air below the chip "MB": (109, 0), # backside metal (zero-thickness PEC) "SUBSTRATE": (110, 0), # silicon "TSV": (111, 0), # through-silicon vias (conductive) "M1": (112, 0), # front-side metal (zero-thickness PEC) "VACUUM": (113, 0), # air above the chip } def _conductor_region( component: gf.Component, sim_region: kdb.Region, etch: tuple[int, int], draw: tuple[int, int], name: str, ) -> kdb.Region: """Return ``SIM_AREA - ETCH + DRAW`` for one metal level. Raises: ValueError: If the level has neither etch nor draw shapes, which the subtractive convention would turn into a solid metal plane. """ layout = component.kdb_cell.layout() etch_region = kdb.Region(component.kdb_cell.begin_shapes_rec(layout.layer(*etch))) draw_region = kdb.Region(component.kdb_cell.begin_shapes_rec(layout.layer(*draw))) if etch_region.is_empty() and draw_region.is_empty(): msg = ( f"metal level {name} has neither etch nor draw shapes;" " the subtractive convention would make it a solid plane" ) raise ValueError(msg) return ((sim_region - etch_region) | (draw_region & sim_region)).merged() def _metal_name(draw: tuple[int, int]) -> str: """Metal level name of a ``*_DRAW`` layer, e.g. ``MB`` for ``MB_DRAW``.""" return str(draw).removesuffix("_DRAW")
[docs] def to_tsv_regions(component: gf.Component, chip: Chip = "bottom") -> gf.Component: """Convert a double-sided qpdk layout into explicit TSV FEM regions. The front (M1) and back (MB) metal levels both follow the subtractive convention of :func:`to_fem_regions` (conductor is ``SIM_AREA - (ETCH - DRAW)``, per ``LAYER_STACK_BACKSIDE``), and the ``TSV`` layer is copied to its own region. Backside shapes are drawn in the front-side frame, as in :func:`~qpdk.cells.tsv.tsv_transition_double_sided`, so no mirroring is applied. The regions land on the layer numbering of :data:`TSV_FEM_LAYERS`. With ``chip="top"`` the flip-chip top chip is converted instead, reading M2, MB2 and TSV2 into the same regions. Args: component: Layout with a ``SIM_AREA`` layer, front and back metal and TSV shapes of ``chip``, plus simulation ports. chip: Chip to convert, ``"bottom"`` or ``"top"``, see :data:`~qpdk.tech.CHIP_LAYERS`. Returns: Component carrying the TSV regions plus the input ports. Raises: ValueError: If the component carries no ``SIM_AREA`` shapes, a metal level has neither etch nor draw shapes, or there are no TSVs inside the simulation area to connect the two faces. """ layout = component.kdb_cell.layout() sim_region = kdb.Region( component.kdb_cell.begin_shapes_rec(layout.layer(*LAYER.SIM_AREA)) ) if sim_region.is_empty(): msg = "no SIM_AREA layer in the component; the FEM model would be empty" raise ValueError(msg) layers = CHIP_LAYERS[chip] m1_region = _conductor_region( component, sim_region, layers.front_etch, layers.front_draw, _metal_name(layers.front_draw), ) mb_region = _conductor_region( component, sim_region, layers.back_etch, layers.back_draw, _metal_name(layers.back_draw), ) tsv_region = ( kdb.Region(component.kdb_cell.begin_shapes_rec(layout.layer(*layers.tsv))) & sim_region ).merged() if tsv_region.is_empty(): msg = "no TSVs in the simulation area; the two faces would not connect" raise ValueError(msg) regions = gf.Component() rl = regions.kdb_cell.layout() for name, region in [ ("VACUUM_BOTTOM", sim_region), ("MB", mb_region), ("SUBSTRATE", sim_region), ("TSV", tsv_region), ("M1", m1_region), ("VACUUM", sim_region), ]: regions.kdb_cell.shapes(rl.layer(*TSV_FEM_LAYERS[name])).insert(region) for port in component.ports: regions.add_port(name=port.name, port=port) return regions
[docs] def tsv_stack( substrate_thickness: float = 200.0, vacuum_thickness: float = 500.0, tsv_conductivity: float = 1.0e6, ) -> Any: """Return a gsim layer stack for a chip with metal on both faces. The front metal M1 sits at ``z = 0`` on top of the silicon and the backside metal MB at ``z = -substrate_thickness`` below it; both become perfect electric conductors. The TSVs are conductive vias spanning the full substrate. Air fills ``vacuum_thickness`` above and below the chip. The default substrate thickness follows the 200 µm TSV depth of :cite:`mallekFabricationSuperconductingThroughsilicon2021`. Args: substrate_thickness: Silicon thickness (TSV depth) in μm. vacuum_thickness: Air height above and below the chip in μm. tsv_conductivity: Normal-state conductivity of the via lining in S/m. The lining is superconducting at operating temperature; any finite value large enough to make the via a good conductor works here. Without a conductivity gsim degrades the via to a 2-D PEC sheet at its base, which no longer bridges the faces. Returns: A ``gsim.common.stack.LayerStack`` (requires gsim from the ``models`` extra). """ from gsim.common.stack import Layer, LayerStack from gsim.common.stack.materials import MATERIALS_DB silicon = material_properties["Si"] h = substrate_thickness stack = LayerStack(pdk_name="qpdk") layers: list[ tuple[str, float, float, str, Literal["conductor", "dielectric", "via"]] ] = [ ( "VACUUM_BOTTOM", -h - vacuum_thickness, vacuum_thickness, "vacuum", "dielectric", ), ("MB", -h, 0, "qpdk-niobium", "conductor"), ("SUBSTRATE", -h, h, "qpdk-silicon", "dielectric"), ("TSV", -h, h, "qpdk-tsv-lining", "via"), ("M1", 0.0, 0, "qpdk-niobium", "conductor"), ("VACUUM", 0.0, vacuum_thickness, "vacuum", "dielectric"), ] for name, zmin, thickness, material, layer_type in layers: stack.layers[name] = Layer( name=name, gds_layer=TSV_FEM_LAYERS[name], zmin=zmin, zmax=zmin + thickness, thickness=thickness, material=material, layer_type=layer_type, ) stack.materials = { "qpdk-silicon": { "permittivity": silicon["relative_permittivity"], "loss_tangent": silicon["loss_tangent"], }, "qpdk-niobium": {"permittivity": 1.0}, "qpdk-tsv-lining": {"conductivity": tsv_conductivity}, "vacuum": MATERIALS_DB["vacuum"].to_dict(), } return stack