r"""Add an electrostatic capacitance study to a COMSOL model that uses metal sheets.
The model comes from
:func:`~qpdk.simulation.comsol.sheet.build_comsol_sheet_model`, where the metal
is a set of faces on the silicon/air interface. This module picks those faces out
with points inside them, drives one of them with a voltage terminal, grounds the
others, and adds a mesh sequence and a stationary study. Nothing is solved here,
and nothing here is specific to a device: the caller names the conductors, which
one is driven, and which are grounded.
A solve gives electrostatic capacitance: ``es.C11`` is the capacitance of the
driven conductor to the grounded rest of the chip, and the stored energy agrees
with it, since :math:`2 W_\text{e} / V^2` equals ``es.C11``. It is not an RF Josephson
eigenmode, and turning it into a qubit frequency needs an :math:`L_\text{J}` picked
outside COMSOL: that LC estimate is not an eigensolve.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from shapely.geometry import Point as ShapelyPoint, Polygon
from qpdk.simulation.comsol._util import _format_number
from qpdk.simulation.comsol.sheet import SILICON_SELECTION
if TYPE_CHECKING:
import mph
from qpdk.simulation.comsol.layout import ComsolLayout, Point
#: Half-size in µm of the boxes that pick one metal face out of the plane. The
#: metal lives on a single plane, so the box straddles ``z = 0``.
_FACE_HALF_SIZE_UM = 0.01
def _require_point(point: Point, name: str) -> None:
"""Raise unless ``point`` is a finite (x, y) pair in µm.
Raises:
ValueError: If the point is not a finite (x, y) pair.
"""
if len(point) != 2 or not all(math.isfinite(value) for value in point):
raise ValueError(f"{name} must be a finite (x, y) pair in µm, got {point!r}")
def _require_conductor_tags(
conductors: tuple[tuple[str, Point], ...],
terminal: str,
grounds: tuple[str, ...],
) -> None:
"""Raise unless the tags name every conductor exactly once.
A tag that repeats, a terminal or ground that names no conductor, and a
conductor that is neither driven nor grounded are all refused: the first two
would point a terminal or a ground at a selection that does not exist, and
the third would leave that face as a dielectric interface, so the
capacitance would be for a geometry other than the one drawn.
Raises:
ValueError: If a conductor tag repeats, if the terminal or a ground is
not one of the conductors, if the terminal is also grounded, if two
grounds are the same tag, or if a conductor is neither driven nor
grounded.
"""
tags = [tag for tag, _ in conductors]
if not grounds:
raise ValueError("at least one grounded conductor is required")
if len(set(tags)) != len(tags):
raise ValueError(f"the conductor tags must be unique, got {tags}")
if terminal not in tags:
raise ValueError(
f"the terminal {terminal!r} is not one of the conductors {tags}"
)
if len(set(grounds)) != len(grounds):
raise ValueError(f"the ground tags must be unique, got {grounds}")
for ground in grounds:
if ground not in tags:
raise ValueError(
f"the ground {ground!r} is not one of the conductors {tags}"
)
if terminal in grounds:
raise ValueError(f"the terminal {terminal!r} must not also be grounded")
unnamed = [tag for tag in tags if tag != terminal and tag not in grounds]
if unnamed:
raise ValueError(
f"the conductors {unnamed} are neither the terminal nor a ground"
)
def _add_face_selection(component: Any, tag: str, point: Point) -> tuple[int, ...]:
"""Add a Box selection over the metal face around ``point`` and read it back.
Returns:
The entities the selection resolved to, as COMSOL reports them.
"""
component.selection().create(tag, "Box")
selection = component.selection(tag)
selection.set("entitydim", "2")
selection.set("condition", "intersects")
selection.set("zmin", _format_number(-_FACE_HALF_SIZE_UM))
selection.set("zmax", _format_number(_FACE_HALF_SIZE_UM))
for axis, value in zip("xy", point):
selection.set(f"{axis}min", _format_number(value - _FACE_HALF_SIZE_UM))
selection.set(f"{axis}max", _format_number(value + _FACE_HALF_SIZE_UM))
return tuple(selection.entities())
def _select_metal_faces(
component: Any, layout: ComsolLayout, conductors: tuple[tuple[str, Point], ...]
) -> None:
"""Add one face selection per conductor and check what they resolved to.
The layout has to hold exactly one metal polygon per conductor, and each
point has to land in a different one. A polygon left over would keep its
metal boundary condition nowhere and be solved as a dielectric interface,
so the capacitance would be for a geometry other than the one drawn. Every
box also has to hold exactly one face, and no two conductors the same one,
or a point that misses the metal would put a terminal or a ground on the
wrong face.
Raises:
ValueError: If the layout does not hold one metal polygon per
conductor, if a point does not lie inside exactly one of them, if
two points lie in the same polygon, if a selection holds a number
of faces other than one, or if two selections hold the same face.
"""
metal_regions = [
Polygon(polygon.outline, polygon.holes) for polygon in layout.polygons
]
if len(metal_regions) != len(conductors):
raise ValueError(
f"the layout must hold one metal polygon per conductor, expected "
f"{len(conductors)}, got {len(metal_regions)}"
)
assigned: set[int] = set()
for tag, point in conductors:
inside = [
index
for index, region in enumerate(metal_regions)
if region.contains(ShapelyPoint(point))
]
if len(inside) != 1:
raise ValueError(
f"the {tag} point must lie inside exactly one metal polygon"
)
if inside[0] in assigned:
raise ValueError(
f"the conductor points must lie in {len(conductors)} different "
"metal polygons"
)
assigned.add(inside[0])
faces = {
tag: _add_face_selection(component, tag, point) for tag, point in conductors
}
for tag, entities in faces.items():
if len(entities) != 1:
raise ValueError(
f"the {tag} point selects {len(entities)} faces, expected one: "
"pass a point well inside the metal face it names"
)
if len(set(faces.values())) != len(faces):
raise ValueError(
f"the conductor points must select {len(conductors)} different faces, "
f"got {faces}"
)
[docs]
def add_electrostatics(
component: Any,
*,
terminal: str,
grounds: tuple[str, ...],
voltage_v: float,
) -> None:
"""Add Electrostatics with one voltage terminal and a ground per selection.
The interface covers every domain, so the default Free Space feature keeps
the air, and a Charge Conservation feature on silicon uses that domain's
material permittivity through the default ``epsilonr_mat``.
Args:
component: The Java component to add the physics to, usually ``comp1``.
terminal: Name of the face selection to drive with the voltage source.
grounds: Names of the face selections to ground, one Ground feature each.
The features are tagged ``gnd1``, ``gnd2``, and so on, in this order.
voltage_v: Terminal voltage in V, positive.
"""
physics = component.physics().create("es", "Electrostatics", "geom1")
physics.create("ccSi", "ChargeConservation", 3)
physics.feature("ccSi").selection().named(SILICON_SELECTION)
physics.create("term1", "Terminal", 2)
terminal_feature = physics.feature("term1")
terminal_feature.selection().named(terminal)
# The default TerminalType is Charge, which would leave the face floating.
terminal_feature.set("TerminalType", "Voltage")
terminal_feature.set("V0", f"{_format_number(voltage_v)}[V]")
for index, selection in enumerate(grounds, start=1):
tag = f"gnd{index}"
physics.create(tag, "Ground", 2)
physics.feature(tag).selection().named(selection)
[docs]
def add_capacitance_study(
model: mph.Model,
layout: ComsolLayout,
*,
conductors: tuple[tuple[str, Point], ...],
terminal: str,
grounds: tuple[str, ...],
voltage_v: float = 1.0,
mesh_size: int = 7,
) -> mph.Model:
"""Add an unsolved electrostatic capacitance study to a sheet model.
Args:
model: The MPh model from
:func:`~qpdk.simulation.comsol.sheet.build_comsol_sheet_model`.
layout: The metal polygons used to build that model. It must hold
exactly one polygon per conductor; a metal polygon that no point
names is refused rather than left as a dielectric interface.
conductors: One ``(tag, point)`` pair per metal face. The tag names the
face selection created on ``comp1``, and the point in µm on the sheet
plane has to sit inside that face. It centres a small box, so it has
to sit clear of the face's edges.
terminal: Tag of the conductor to drive with the voltage terminal.
grounds: Tags of the conductors to ground, in the order their Ground
features are tagged. Together with ``terminal`` they have to name
every conductor exactly once.
voltage_v: Terminal voltage in V, positive.
mesh_size: COMSOL mesh size, an integer from 1 (finest) to 9 (coarsest).
Returns:
The same model, with the selections, physics, mesh, and study added and
nothing solved. Once the caller runs them, ``es.C11`` agrees with
``2*es.intWe/voltage_v**2``.
Raises:
ValueError: If the voltage is not positive and finite, if the mesh size
is not an integer in 1 to 9, if a point is not a finite (x, y) pair,
if the conductor tags do not name a terminal and a ground each, or
if the layout, the polygons, or the points do not assign exactly one
distinct metal face to each conductor.
"""
if not math.isfinite(voltage_v) or voltage_v <= 0.0:
raise ValueError(f"voltage_v must be positive and finite, got {voltage_v!r}")
if (
isinstance(mesh_size, bool)
or not isinstance(mesh_size, int)
or not 1 <= mesh_size <= 9
):
raise ValueError(
"mesh_size must be an integer from 1 (finest) to 9 (coarsest), "
f"got {mesh_size!r}"
)
_require_conductor_tags(conductors, terminal, grounds)
for tag, point in conductors:
_require_point(point, tag)
component = model.java.component("comp1")
_select_metal_faces(component, layout, conductors)
add_electrostatics(
component, terminal=terminal, grounds=grounds, voltage_v=voltage_v
)
mesh = component.mesh().create("mesh1", "geom1")
mesh.autoMeshSize(mesh_size)
study = model.java.study().create("std1")
study.create("stat", "Stationary")
return model