gsim

Functions

get_status

get_status(job_id: str) -> str

Get the current status of a cloud job.

Parameters:

Name Type Description Default
job_id str

Job identifier.

required

Returns:

Type Description
str

Status string — one of "created", "queued",

str

"running", "completed", "failed".

Source code in src/gsim/gcloud.py
def get_status(job_id: str) -> str:
    """Get the current status of a cloud job.

    Args:
        job_id: Job identifier.

    Returns:
        Status string — one of ``"created"``, ``"queued"``,
        ``"running"``, ``"completed"``, ``"failed"``.
    """
    job = sim.get_job(job_id)
    return job.status.value

wait_for_results

wait_for_results(
    *job_ids: str,
    verbose: bool = True,
    parent_dir: str | Path | None = None,
    poll_interval: float = 5.0,
) -> Any

Wait for one or more jobs to finish, then download and parse results.

Accepts job IDs as positional args or a single list/tuple::

wait_for_results(id1, id2)
wait_for_results([id1, id2])

For a single job, returns the parsed result directly. For multiple jobs, returns a list of results (same order as input).

Parameters:

Name Type Description Default
*job_ids str

One or more job ID strings, or a single list/tuple of IDs.

()
verbose bool

Print progress messages.

True
parent_dir str | Path | None

Where to create sim-data directories (default: cwd).

None
poll_interval float

Seconds between status polls (default 5.0).

5.0

Returns:

Type Description
Any

Parsed result (single job) or list of parsed results (multiple jobs).

Source code in src/gsim/gcloud.py
def wait_for_results(
    *job_ids: str,
    verbose: bool = True,
    parent_dir: str | Path | None = None,
    poll_interval: float = 5.0,
) -> Any:
    """Wait for one or more jobs to finish, then download and parse results.

    Accepts job IDs as positional args or a single list/tuple::

        wait_for_results(id1, id2)
        wait_for_results([id1, id2])

    For a single job, returns the parsed result directly.
    For multiple jobs, returns a list of results (same order as input).

    Args:
        *job_ids: One or more job ID strings, or a single list/tuple of IDs.
        verbose: Print progress messages.
        parent_dir: Where to create sim-data directories (default: cwd).
        poll_interval: Seconds between status polls (default 5.0).

    Returns:
        Parsed result (single job) or list of parsed results (multiple jobs).
    """
    # Support both varargs and a single list/tuple
    if len(job_ids) == 1 and isinstance(job_ids[0], (list, tuple)):
        job_ids = tuple(job_ids[0])

    if not job_ids:
        raise ValueError("At least one job_id is required")

    # Fetch initial job objects
    jobs: dict[str, Any] = {jid: sim.get_job(jid) for jid in job_ids}
    now = time.monotonic()
    start_times: dict[str, float] = dict.fromkeys(job_ids, now)
    end_times: dict[str, float] = {}
    terminal = {sim.SimStatus.COMPLETED, sim.SimStatus.FAILED}

    # Freeze timer for any jobs already finished
    for jid, job in jobs.items():
        if job.status in terminal:
            end_times[jid] = now

    # Track how many lines we printed last time (for overwriting multi-job)
    prev_lines = 0

    # Poll until all jobs reach a terminal state
    while not all(j.status in terminal for j in jobs.values()):
        if verbose:
            prev_lines = _print_status_table(
                jobs, start_times, prev_lines, end_times=end_times
            )
        time.sleep(poll_interval)
        for jid, job in jobs.items():
            if job.status not in terminal:
                jobs[jid] = sim.get_job(jid)
                # Freeze timer when job reaches terminal state
                if jobs[jid].status in terminal:
                    end_times[jid] = time.monotonic()

    # Final status display (with newline to finish the line)
    if verbose:
        _print_status_table(
            jobs, start_times, prev_lines, end_times=end_times, final=True
        )

    # Download + parse all
    results = []
    for jid in job_ids:
        job = jobs[jid]
        run_result = _download_job(job, parent_dir, verbose)
        results.append(_parse_result(job, run_result))

    return results[0] if len(job_ids) == 1 else results

gsim.common

Classes

Geometry

Bases: BaseModel

Shared geometry wrapper for gdsfactory Component.

This class wraps a gdsfactory Component and provides computed properties that are useful for simulation setup (bounds, ports, etc.).

Attributes:

Name Type Description
component Any

The wrapped gdsfactory Component

Example

from gdsfactory.components import straight c = straight(length=100) geom = Geometry(component=c) print(geom.bounds) (0.0, -0.25, 100.0, 0.25)

Methods:

Name Description
get_port

Get a port by name.

bounds cached property

bounds: tuple[float, float, float, float]

Get bounding box (xmin, ymin, xmax, ymax) in um.

height property

height: float

Get height (y-extent) of geometry in um.

port_names cached property

port_names: list[str]

Get list of port names.

ports cached property

ports: list

Get list of ports on the component.

width property

width: float

Get width (x-extent) of geometry in um.

get_port

get_port(name: str) -> Any

Get a port by name.

Parameters:

Name Type Description Default
name str

Port name to find

required

Returns:

Type Description
Any

Port object if found, None otherwise

Source code in src/gsim/common/geometry.py
def get_port(self, name: str) -> Any:
    """Get a port by name.

    Args:
        name: Port name to find

    Returns:
        Port object if found, None otherwise
    """
    for port in self.component.ports:
        if port.name == name:
            return port
    return None

GeometryModel dataclass

GeometryModel(
    prisms: dict[str, list[Prism]],
    bbox: tuple[tuple[float, float, float], tuple[float, float, float]],
    layer_bboxes: dict[
        str, tuple[tuple[float, float, float], tuple[float, float, float]]
    ] = dict(),
    layer_mesh_orders: dict[str, int] = dict(),
)

Complete 3D geometry: layers of prisms, ready for visualization.

Attributes:

Name Type Description
prisms dict[str, list[Prism]]

Mapping from layer name to list of Prism objects.

bbox tuple[tuple[float, float, float], tuple[float, float, float]]

Axis-aligned 3D bounding box as ((xmin, ymin, zmin), (xmax, ymax, zmax)).

layer_bboxes dict[str, tuple[tuple[float, float, float], tuple[float, float, float]]]

Optional per-layer bounding boxes for 2D slice logic.

layer_mesh_orders dict[str, int]

Optional mapping of layer_name -> mesh_order for z-ordering in 2D plots.

Methods:

Name Description
get_layer_bbox

Return the bounding box for a specific layer.

get_layer_center

Return the center of a layer's bounding box.

all_prisms property

all_prisms: list[Prism]

Flat list of all prisms across all layers.

layer_names property

layer_names: list[str]

Ordered list of layer names that contain prisms.

size property

(dx, dy, dz) extent of the bounding box.

get_layer_bbox

get_layer_bbox(
    layer_name: str,
) -> tuple[tuple[float, float, float], tuple[float, float, float]]

Return the bounding box for a specific layer.

Falls back to the geometry-wide bbox if no per-layer bbox is stored.

Source code in src/gsim/common/geometry_model.py
def get_layer_bbox(
    self,
    layer_name: str,
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
    """Return the bounding box for a specific layer.

    Falls back to the geometry-wide bbox if no per-layer bbox is stored.
    """
    return self.layer_bboxes.get(layer_name, self.bbox)

get_layer_center

get_layer_center(layer_name: str) -> tuple[float, float, float]

Return the center of a layer's bounding box.

Falls back to the geometry-wide center if no per-layer bbox is stored.

Source code in src/gsim/common/geometry_model.py
def get_layer_center(self, layer_name: str) -> tuple[float, float, float]:
    """Return the center of a layer's bounding box.

    Falls back to the geometry-wide center if no per-layer bbox is stored.
    """
    bb = self.get_layer_bbox(layer_name)
    mn, mx = bb
    return (
        (mn[0] + mx[0]) / 2.0,
        (mn[1] + mx[1]) / 2.0,
        (mn[2] + mx[2]) / 2.0,
    )

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."""
    d = {
        "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,
    }
    if self.sidewall_angle != 0.0:
        d["sidewall_angle"] = self.sidewall_angle
    return d

LayerStack

Bases: BaseModel

Complete layer stack for Palace simulation.

Methods:

Name Description
from_layer_list

Build a LayerStack from a list of Layer objects.

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.

from_layer_list classmethod

from_layer_list(layerList: list[Layer]) -> LayerStack

Build a LayerStack from a list of Layer objects.

Parameters:

Name Type Description Default
layerList list[Layer]

List of Layer definitions to include in the stack.

required

Returns:

Type Description
LayerStack

A LayerStack with layers/materials/dielectrics assembled from layerList.

Raises:

Type Description
ValueError

If layerList is None or empty.

Source code in src/gsim/common/stack/extractor.py
@classmethod
def from_layer_list(cls, layerList: list[Layer]) -> LayerStack:
    """Build a LayerStack from a list of Layer objects.

    Args:
        layerList: List of Layer definitions to include in the stack.

    Returns:
        A LayerStack with layers/materials/dielectrics assembled from `layerList`.

    Raises:
        ValueError: If `layerList` is None or empty.
    """
    if not layerList:
        raise ValueError("None or empty layer list")
    # Build Layer dict
    layer_dict = {}
    for layer in layerList:
        if layer.name in layer_dict:
            raise ValueError(f"Duplicate layer name: {layer.name}")
        layer_dict[layer.name] = layer
    # Build materials dict
    material_dict = {}
    for layer in layerList:
        material_dict[layer.material] = MATERIALS_DB[layer.material].to_dict()
    # Build dielectric list
    dielectric_list = []
    for layer in layerList:
        if layer.layer_type == "dielectric":
            dielectric = {
                "name": layer.name,
                "zmin": layer.zmin,
                "zmax": layer.zmax,
                "material": layer.material,
            }
            dielectric_list.append(dielectric)
    # Create layer stack and export to YAML
    layer_stack = LayerStack(
        layers=layer_dict, materials=material_dict, dielectrics=dielectric_list
    )
    return layer_stack

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)

Prism dataclass

Prism(
    vertices: ndarray,
    z_base: float,
    z_top: float,
    layer_name: str = "",
    material: str = "",
    sidewall_angle: float = 0.0,
    original_polygon: Any = None,
)

A 2D polygon extruded in z. No solver dependency.

Attributes:

Name Type Description
vertices ndarray

(N, 2) numpy array of xy coordinates defining the polygon.

z_base float

Bottom z coordinate of the extrusion.

z_top float

Top z coordinate of the extrusion.

layer_name str

Name of the layer this prism belongs to.

material str

Name of the material assigned to this prism.

sidewall_angle float

Sidewall taper angle in degrees (gdsfactory convention).

original_polygon Any

Optional reference to the source Shapely polygon.

height property

height: float

Extrusion height (z_top - z_base).

z_center property

z_center: float

Center z coordinate.

ValidationResult

Bases: BaseModel

Result of stack validation.

Functions

decimate

decimate(
    polygons: list[Polygon], relative_tolerance: float = 0.001, *, verbose: bool = False
) -> list[Polygon]

Reduce vertex count of a list of KLayout polygons.

Only polygons with more than 20 hull vertices are simplified; simpler shapes are kept as-is.

Parameters:

Name Type Description Default
polygons list[Polygon]

Input polygon list.

required
relative_tolerance float

Fraction of polygon size used as tolerance.

0.001
verbose bool

Print per-polygon reduction statistics.

False

Returns:

Type Description
list[Polygon]

List of (possibly simplified) polygons.

Source code in src/gsim/common/polygon_utils.py
def decimate(
    polygons: list[kdb.Polygon],
    relative_tolerance: float = 0.001,
    *,
    verbose: bool = False,
) -> list[kdb.Polygon]:
    """Reduce vertex count of a list of KLayout polygons.

    Only polygons with more than 20 hull vertices are simplified; simpler
    shapes are kept as-is.

    Args:
        polygons: Input polygon list.
        relative_tolerance: Fraction of polygon size used as tolerance.
        verbose: Print per-polygon reduction statistics.

    Returns:
        List of (possibly simplified) polygons.
    """
    out: list[kdb.Polygon] = []
    total_before = total_after = 0

    for i, poly in enumerate(polygons):
        n = poly.num_points_hull()
        total_before += n

        if n > 20:
            tol = _adaptive_tolerance(poly, relative_tolerance)
            reduced = simplify_polygon(poly, tolerance=tol)
            rn = reduced.num_points_hull()
            if verbose and rn != n:
                logger.debug(
                    "Polygon %d: %d -> %d pts (tol=%.3f um, %.1fx reduction)",
                    i,
                    n,
                    rn,
                    tol,
                    n / rn,
                )
            out.append(reduced)
            total_after += rn
        else:
            out.append(poly)
            total_after += n

    if verbose:
        pct = (total_before - total_after) / max(total_before, 1) * 100
        logger.debug(
            "Decimation: %d -> %d pts (%.1f%% removed)", total_before, total_after, pct
        )

    return out

extract_geometry_model

extract_geometry_model(layered_component: LayeredComponentBase) -> GeometryModel

Convert a LayeredComponentBase into a GeometryModel with generic Prisms.

For each geometry layer (sorted by mesh_order): 1. Retrieve the merged Shapely polygon from layered_component.polygons. 2. Compute z_base / z_top from get_layer_bbox. 3. Iterate sub-polygons for MultiPolygon geometries. 4. Handle polygons with holes via Delaunay triangulation. 5. Produce Prism objects with (N, 2) numpy vertex arrays.

Parameters:

Name Type Description Default
layered_component LayeredComponentBase

A LayeredComponentBase (or subclass) instance that provides polygons, geometry_layers, and get_layer_bbox.

required

Returns:

Type Description
GeometryModel

A GeometryModel containing all extracted prisms and the overall

GeometryModel

3D bounding box.

Source code in src/gsim/common/geometry_model.py
def extract_geometry_model(
    layered_component: LayeredComponentBase,
) -> GeometryModel:
    """Convert a LayeredComponentBase into a GeometryModel with generic Prisms.

    For each geometry layer (sorted by mesh_order):
      1. Retrieve the merged Shapely polygon from *layered_component.polygons*.
      2. Compute z_base / z_top from *get_layer_bbox*.
      3. Iterate sub-polygons for MultiPolygon geometries.
      4. Handle polygons with holes via Delaunay triangulation.
      5. Produce Prism objects with (N, 2) numpy vertex arrays.

    Args:
        layered_component: A LayeredComponentBase (or subclass) instance
            that provides polygons, geometry_layers, and get_layer_bbox.

    Returns:
        A GeometryModel containing all extracted prisms and the overall
        3D bounding box.
    """
    all_prisms: dict[str, list[Prism]] = {}
    layer_bboxes: dict[
        str,
        tuple[tuple[float, float, float], tuple[float, float, float]],
    ] = {}
    layer_mesh_orders: dict[str, int] = {}

    # Sort layers by mesh_order (ascending) for consistent rendering order
    sorted_layers = sorted(
        layered_component.geometry_layers.items(),
        key=lambda item: item[1].mesh_order,
    )

    for name, layer in sorted_layers:
        shape = layered_component.polygons[name]
        bbox = layered_component.get_layer_bbox(name)
        layer_bboxes[name] = bbox
        layer_mesh_orders[name] = (
            layer.mesh_order if layer.mesh_order is not None else 0
        )
        z_base = bbox[0][2]
        z_top = bbox[1][2]

        sidewall_angle = layer.sidewall_angle or 0.0
        material_name = str(layer.material) if layer.material else ""

        # Normalise to a list of individual Polygon objects
        if hasattr(shape, "geoms"):
            polygons: list[Polygon] = list(shape.geoms)
        else:
            polygons = [shape]

        layer_prisms: list[Prism] = []

        for polygon in polygons:
            if polygon.is_empty or not polygon.is_valid:
                continue

            if hasattr(polygon, "interiors") and polygon.interiors:
                layer_prisms.extend(
                    _triangulate_polygon_with_holes(
                        polygon,
                        z_base=z_base,
                        z_top=z_top,
                        sidewall_angle=sidewall_angle,
                        layer_name=name,
                        material=material_name,
                    ),
                )
            else:
                coords = np.array(polygon.exterior.coords[:-1])
                layer_prisms.append(
                    Prism(
                        vertices=coords,
                        z_base=z_base,
                        z_top=z_top,
                        layer_name=name,
                        material=material_name,
                        sidewall_angle=sidewall_angle,
                        original_polygon=polygon,
                    ),
                )

        all_prisms[name] = layer_prisms

    return GeometryModel(
        prisms=all_prisms,
        bbox=layered_component.bbox,
        layer_bboxes=layer_bboxes,
        layer_mesh_orders=layer_mesh_orders,
    )

klayout_to_shapely

klayout_to_shapely(polygon_kdb: Polygon) -> Polygon

Convert a KLayout polygon (with optional holes) to a Shapely Polygon.

Source code in src/gsim/common/polygon_utils.py
def klayout_to_shapely(polygon_kdb: kdb.Polygon) -> Polygon:
    """Convert a KLayout polygon (with optional holes) to a Shapely Polygon."""
    exterior_coords = [
        (gf.kcl.to_um(pt.x), gf.kcl.to_um(pt.y)) for pt in polygon_kdb.each_point_hull()
    ]
    holes = []
    for hole_idx in range(polygon_kdb.holes()):
        hole_coords = [
            (gf.kcl.to_um(pt.x), gf.kcl.to_um(pt.y))
            for pt in polygon_kdb.each_point_hole(hole_idx)
        ]
        holes.append(hole_coords)

    return Polygon(exterior_coords, holes) if holes else Polygon(exterior_coords)

shapely_to_klayout

shapely_to_klayout(shapely_poly: Polygon) -> Polygon | None

Convert a Shapely Polygon back to a KLayout polygon.

Returns None if the polygon is empty, invalid, or has fewer than 3 exterior vertices.

Source code in src/gsim/common/polygon_utils.py
def shapely_to_klayout(shapely_poly: Polygon) -> kdb.Polygon | None:
    """Convert a Shapely Polygon back to a KLayout polygon.

    Returns ``None`` if the polygon is empty, invalid, or has fewer than
    3 exterior vertices.
    """
    try:
        if shapely_poly.is_empty or not shapely_poly.is_valid:
            return None
        if shapely_poly.geom_type != "Polygon":
            return None

        exterior_coords = list(shapely_poly.exterior.coords[:-1])
        if len(exterior_coords) < 3:
            return None

        exterior_points = [
            kdb.Point(int(gf.kcl.to_dbu(x)), int(gf.kcl.to_dbu(y)))
            for x, y in exterior_coords
        ]
        polygon = kdb.Polygon(kdb.SimplePolygon(exterior_points))

        for hole_ring in shapely_poly.interiors:
            hole_coords = list(hole_ring.coords[:-1])
            if len(hole_coords) >= 3:
                hole_points = [
                    kdb.Point(int(gf.kcl.to_dbu(x)), int(gf.kcl.to_dbu(y)))
                    for x, y in hole_coords
                ]
                polygon.insert_hole(hole_points)

    except Exception as e:
        logger.warning("Shapely to KLayout conversion failed: %s", e)
        return None
    else:
        return polygon

gsim.common.viz

Functions

create_web_export

create_web_export(
    geometry_model: GeometryModel,
    filename: str = "geometry_3d.html",
    title: str = "3D Geometry Visualization",
) -> str

Export 3D visualisation as standalone HTML (via PyVista).

Source code in src/gsim/common/viz/render3d_pyvista.py
def create_web_export(
    geometry_model: GeometryModel,
    filename: str = "geometry_3d.html",
    title: str = "3D Geometry Visualization",  # noqa: ARG001
) -> str:
    """Export 3D visualisation as standalone HTML (via PyVista)."""
    if not PYVISTA_AVAILABLE:
        raise ImportError("PyVista is required for web export.")

    plotter = pv.Plotter(notebook=False, off_screen=True)
    layer_meshes = _convert_prisms_to_meshes(geometry_model)
    colors = generate_layer_colors(list(layer_meshes.keys()))

    for layer_name, meshes in layer_meshes.items():
        color = colors[layer_name]
        for mesh in meshes:
            plotter.add_mesh(mesh, color=color, opacity=0.8)

    plotter.export_html(filename)
    return filename

export_3d_mesh

export_3d_mesh(geometry_model: GeometryModel, filename: str, fmt: str = 'auto') -> None

Export 3D geometry to mesh file (STL, PLY, OBJ, VTK, glTF).

Source code in src/gsim/common/viz/render3d_pyvista.py
def export_3d_mesh(
    geometry_model: GeometryModel,
    filename: str,
    fmt: str = "auto",
) -> None:
    """Export 3D geometry to mesh file (STL, PLY, OBJ, VTK, glTF)."""
    if not PYVISTA_AVAILABLE:
        raise ImportError(
            "PyVista is required for mesh export. Install with: pip install pyvista"
        )

    layer_meshes = _convert_prisms_to_meshes(geometry_model)
    combined = pv.MultiBlock()
    for layer_name, meshes in layer_meshes.items():
        for i, mesh in enumerate(meshes):
            combined[f"{layer_name}_{i}"] = mesh

    if fmt == "auto":
        fmt = filename.rsplit(".", 1)[-1].lower()

    if fmt == "stl":
        combined.combine().save(filename)
    elif fmt in ("ply", "obj", "vtk"):
        combined.save(filename)
    elif fmt == "gltf":
        try:
            combined.save(filename)
        except Exception as e:
            raise ValueError(
                f"glTF export failed. May need additional dependencies: {e}"
            ) from e
    else:
        raise ValueError(f"Unsupported format: {fmt}")

plot_prism_slices

plot_prism_slices(
    geometry_model: GeometryModel,
    x: float | str | None = None,
    y: float | str | None = None,
    z: float | str = "core",
    ax: Axes | None = None,
    legend: bool = True,
    slices: str = "z",
    *,
    overlay: Any | None = None,
) -> Axes | None

Plot cross sections of a GeometryModel with multi-view support.

Parameters:

Name Type Description Default
geometry_model GeometryModel

GeometryModel with prisms and bbox.

required
x float | str | None

X-coordinate (or layer name) for the slice plane.

None
y float | str | None

Y-coordinate (or layer name) for the slice plane.

None
z float | str

Z-coordinate (or layer name) for the slice plane.

'core'
ax Axes | None

Axes to draw on. If None, a new figure is created.

None
legend bool

Whether to show the legend.

True
slices str

Which slice(s) to plot -- "x", "y", "z", or combinations like "xy", "xz", "yz", "xyz".

'z'
overlay Any | None

Optional SimOverlay with sim cell / PML / port metadata.

None

Returns:

Type Description
Axes | None

plt.Axes when ax was provided, otherwise None

Axes | None

(the figure is shown directly).

Source code in src/gsim/common/viz/render2d.py
def plot_prism_slices(
    geometry_model: GeometryModel,
    x: float | str | None = None,
    y: float | str | None = None,
    z: float | str = "core",
    ax: plt.Axes | None = None,
    legend: bool = True,
    slices: str = "z",
    *,
    overlay: Any | None = None,
) -> plt.Axes | None:
    """Plot cross sections of a GeometryModel with multi-view support.

    Args:
        geometry_model: GeometryModel with prisms and bbox.
        x: X-coordinate (or layer name) for the slice plane.
        y: Y-coordinate (or layer name) for the slice plane.
        z: Z-coordinate (or layer name) for the slice plane.
        ax: Axes to draw on.  If ``None``, a new figure is created.
        legend: Whether to show the legend.
        slices: Which slice(s) to plot -- "x", "y", "z", or combinations
            like "xy", "xz", "yz", "xyz".
        overlay: Optional SimOverlay with sim cell / PML / port metadata.

    Returns:
        ``plt.Axes`` when *ax* was provided, otherwise ``None``
        (the figure is shown directly).
    """
    slices_to_plot = sorted(set(slices.lower()))
    if not all(s in "xyz" for s in slices_to_plot):
        raise ValueError(f"slices must only contain 'x', 'y', 'z'. Got: {slices}")

    if ax is not None:
        if len(slices_to_plot) > 1:
            raise ValueError("Cannot plot multiple slices when ax is provided")
        slice_axis = slices_to_plot[0]
        if slice_axis == "x":
            x_val = x if x is not None else "core"
            return _plot_single_prism_slice(
                geometry_model,
                x=x_val,
                y=None,
                z=None,
                ax=ax,
                legend=legend,
                overlay=overlay,
            )
        if slice_axis == "y":
            y_val = y if y is not None else "core"
            return _plot_single_prism_slice(
                geometry_model,
                x=None,
                y=y_val,
                z=None,
                ax=ax,
                legend=legend,
                overlay=overlay,
            )
        if slice_axis == "z":
            return _plot_single_prism_slice(
                geometry_model,
                x=None,
                y=None,
                z=z,
                ax=ax,
                legend=legend,
                overlay=overlay,
            )

    _plot_multi_view(
        geometry_model,
        slices_to_plot,
        x,
        y,
        z,
        show_legend=legend,
        overlay=overlay,
    )
    return None

plot_prisms_3d

plot_prisms_3d(
    geometry_model: GeometryModel,
    *,
    show_edges: bool = True,
    opacity: float = 0.8,
    color_by_layer: bool = True,
    show_simulation_box: bool = True,
    camera_position: str | None = "isometric",
    notebook: bool = True,
    theme: str = "default",
    **kwargs: Any,
) -> Any | None

Create interactive 3D visualisation of prisms using PyVista.

Parameters:

Name Type Description Default
geometry_model GeometryModel

A GeometryModel with prisms and bbox.

required
show_edges bool

Whether to show edges of the prisms.

True
opacity float

Base opacity (0.0-1.0). Core layer forced opaque.

0.8
color_by_layer bool

Colour by layer name.

True
show_simulation_box bool

Draw the simulation bounding box.

True
camera_position str | None

"isometric", "xy", "xz", "yz", or custom tuple.

'isometric'
notebook bool

Whether running inside Jupyter.

True
theme str

PyVista theme ("default", "dark", "document").

'default'
**kwargs Any

Extra args forwarded to pv.Plotter.

{}

Returns:

Type Description
Any | None

PyVista plotter object for further customisation.

Source code in src/gsim/common/viz/render3d_pyvista.py
def plot_prisms_3d(
    geometry_model: GeometryModel,
    *,
    show_edges: bool = True,
    opacity: float = 0.8,  # noqa: ARG001
    color_by_layer: bool = True,
    show_simulation_box: bool = True,
    camera_position: str | None = "isometric",
    notebook: bool = True,
    theme: str = "default",
    **kwargs: Any,
) -> Any | None:
    """Create interactive 3D visualisation of prisms using PyVista.

    Args:
        geometry_model: A GeometryModel with prisms and bbox.
        show_edges: Whether to show edges of the prisms.
        opacity: Base opacity (0.0-1.0). Core layer forced opaque.
        color_by_layer: Colour by layer name.
        show_simulation_box: Draw the simulation bounding box.
        camera_position: "isometric", "xy", "xz", "yz", or custom tuple.
        notebook: Whether running inside Jupyter.
        theme: PyVista theme ("default", "dark", "document").
        **kwargs: Extra args forwarded to ``pv.Plotter``.

    Returns:
        PyVista plotter object for further customisation.
    """
    if not PYVISTA_AVAILABLE:
        raise ImportError(
            "PyVista is required for 3D visualization. "
            "Install with: pip install pyvista"
        )

    plotter = (
        pv.Plotter(notebook=notebook, **kwargs) if notebook else pv.Plotter(**kwargs)
    )

    if theme == "dark":
        pv.set_plot_theme("dark")
    elif theme == "document":
        pv.set_plot_theme("document")

    layer_meshes = _convert_prisms_to_meshes(geometry_model)
    colors = generate_layer_colors(list(layer_meshes.keys()))

    for layer_name, meshes in layer_meshes.items():
        color = colors[layer_name] if color_by_layer else None
        layer_opacity = 1.0 if layer_name == "core" else 0.2

        for mesh in meshes:
            plotter.add_mesh(
                mesh,
                color=color,
                opacity=layer_opacity,
                show_edges=show_edges,
                label=layer_name,
                name=f"{layer_name}_{id(mesh)}",
            )

    if show_simulation_box:
        sim_box = _create_simulation_box_pv(geometry_model)
        plotter.add_mesh(
            sim_box,
            style="wireframe",
            color="black",
            line_width=2,
            label="Simulation Box",
        )

    _set_camera_position(plotter, camera_position)

    if color_by_layer and len(layer_meshes) > 1:
        try:  # noqa: SIM105
            plotter.add_legend()
        except Exception:
            pass

    if notebook:
        try:
            pv.set_jupyter_backend("trame")
            return plotter.show()
        except Exception:
            pv.set_jupyter_backend("static")
            return plotter.show()
    else:
        return plotter.show()

plot_prisms_3d_open3d

plot_prisms_3d_open3d(
    geometry_model: GeometryModel,
    *,
    show_edges: bool = False,
    color_by_layer: bool = True,
    show_simulation_box: bool = True,
    notebook: bool = True,
    layer_opacity: dict[str, float] | None = None,
    **kwargs: Any,
) -> None

Create interactive 3D visualisation using Open3D + Plotly.

Parameters:

Name Type Description Default
geometry_model GeometryModel

GeometryModel containing prisms and bbox.

required
show_edges bool

Show wireframe edges.

False
color_by_layer bool

Colour each layer differently.

True
show_simulation_box bool

Draw the simulation box.

True
notebook bool

Display inside Jupyter notebook.

True
layer_opacity dict[str, float] | None

Per-layer opacity override (default: core=1.0, else 0.2).

None
**kwargs Any

Extra Plotly figure options.

{}
Source code in src/gsim/common/viz/render3d_open3d.py
def plot_prisms_3d_open3d(
    geometry_model: GeometryModel,
    *,
    show_edges: bool = False,
    color_by_layer: bool = True,
    show_simulation_box: bool = True,
    notebook: bool = True,
    layer_opacity: dict[str, float] | None = None,
    **kwargs: Any,
) -> None:
    """Create interactive 3D visualisation using Open3D + Plotly.

    Args:
        geometry_model: GeometryModel containing prisms and bbox.
        show_edges: Show wireframe edges.
        color_by_layer: Colour each layer differently.
        show_simulation_box: Draw the simulation box.
        notebook: Display inside Jupyter notebook.
        layer_opacity: Per-layer opacity override (default: core=1.0, else 0.2).
        **kwargs: Extra Plotly figure options.
    """
    if not OPEN3D_AVAILABLE:
        raise ImportError("Open3D is required. Install with: pip install open3d")

    try:
        import plotly.graph_objects as go
    except ImportError as err:
        raise ImportError(
            "Plotly is required for Open3D notebook visualization. "
            "Install with: pip install plotly"
        ) from err

    layer_meshes = _convert_prisms_to_open3d(geometry_model)
    colors, opacity_dict = generate_layer_colors_with_opacity(
        list(layer_meshes.keys()), layer_opacity
    )

    plotly_meshes: list[Any] = []

    for layer_name, meshes in layer_meshes.items():
        layer_color = colors[layer_name] if color_by_layer else [0.7, 0.7, 0.7]
        layer_opacity_val = opacity_dict.get(layer_name, 0.8)

        for i, mesh in enumerate(meshes):
            if color_by_layer:
                mesh.paint_uniform_color(layer_color[:3])

            plotly_meshes.append(
                _mesh_to_mesh3d(
                    mesh,
                    opacity=layer_opacity_val,
                    name=f"{layer_name}_{i}",
                    color=layer_color[:3] if color_by_layer else [0.7, 0.7, 0.7],
                )
            )

            if show_edges:
                plotly_meshes.append(
                    _wireframe_to_scatter3d(mesh, name=f"{layer_name}_edges_{i}")
                )

    if show_simulation_box:
        plotly_meshes.append(_create_simulation_box_plotly(geometry_model))

    fig = go.Figure(data=plotly_meshes)

    # Compute axis ranges for zoom
    all_x: list[float] = []
    all_y: list[float] = []
    all_z: list[float] = []
    for meshes in layer_meshes.values():
        for mesh in meshes:
            verts = np.asarray(mesh.vertices)
            all_x.extend(verts[:, 0])
            all_y.extend(verts[:, 1])
            all_z.extend(verts[:, 2])

    if all_x:
        cx = np.mean([min(all_x), max(all_x)])
        cy = np.mean([min(all_y), max(all_y)])
        cz = np.mean([min(all_z), max(all_z)])
        rs = max(
            max(all_x) - min(all_x),
            max(all_y) - min(all_y),
            max(all_z) - min(all_z),
        )
    else:
        cx = cy = cz = 0.0
        rs = 10.0

    # Standard camera views for view buttons
    d = 2.0  # distance multiplier for orthographic views
    _views = {
        "Iso": dict(eye=dict(x=1.5, y=1.5, z=1.5)),
        "Top": dict(eye=dict(x=0, y=0, z=d), up=dict(x=0, y=1, z=0)),
        "Front": dict(eye=dict(x=0, y=-d, z=0), up=dict(x=0, y=0, z=1)),
        "Right": dict(eye=dict(x=d, y=0, z=0), up=dict(x=0, y=0, z=1)),
    }
    view_buttons = [
        dict(
            label=name,
            method="relayout",
            args=[
                {
                    "scene.camera.eye": cam["eye"],
                    "scene.camera.up": cam.get("up", dict(x=0, y=0, z=1)),
                    "scene.camera.center": dict(x=0, y=0, z=0),
                }
            ],
        )
        for name, cam in _views.items()
    ]

    fig.update_layout(
        scene=dict(
            xaxis_title="X (um)",
            yaxis_title="Y (um)",
            zaxis_title="Z (um)",
            aspectmode="cube",
            camera=dict(
                eye=dict(x=1.5, y=1.5, z=1.5),
                center=dict(x=0, y=0, z=0),
                projection=dict(type="orthographic"),
            ),
            xaxis=dict(range=[cx - rs * 0.6, cx + rs * 0.6]),
            yaxis=dict(range=[cy - rs * 0.6, cy + rs * 0.6]),
            zaxis=dict(range=[cz - rs * 0.6, cz + rs * 0.6]),
        ),
        title="",
        dragmode="orbit",
        updatemenus=[
            dict(
                type="buttons",
                direction="down",
                x=0.01,
                y=0.99,
                xanchor="left",
                yanchor="top",
                bgcolor="rgba(255,255,255,0.8)",
                buttons=view_buttons,
            ),
        ],
        **kwargs,
    )

    config = {
        "scrollZoom": True,
        "doubleClick": "reset+autosize",
        "modeBarButtonsToRemove": ["pan2d", "lasso2d"],
        "displayModeBar": True,
        "responsive": True,
    }

    if notebook:
        fig.show(config=config)
    else:
        fig.write_html("geometry_3d.html", config=config)
        import webbrowser

        webbrowser.open("geometry_3d.html")

gsim.palace

Classes

CPWPortConfig

Bases: BaseModel

Configuration for a coplanar waveguide (CPW) port.

CPW ports consist of two elements (upper and lower gaps) that are excited with opposite E-field directions to create the CPW mode.

The port is placed at the center of the signal conductor. The two gap element surfaces are computed from s_width and gap_width.

Attributes:

Name Type Description
name str

Port name (must match a single component port at the signal center)

layer str

Target conductor layer

s_width float

Width of the signal (center) conductor (um)

gap_width float

Width of each gap between signal and ground (um)

length float

Port extent along direction (um)

offset float

Shift the port along the waveguide direction (um). Positive moves in the port orientation direction.

impedance float

Port impedance (Ohms)

excited bool

Whether this port is excited

DrivenConfig

Bases: BaseModel

Configuration for driven (frequency sweep) simulation.

This is used for S-parameter extraction and frequency response analysis.

Attributes:

Name Type Description
fmin float

Minimum frequency in Hz

fmax float

Maximum frequency in Hz

num_points int

Number of frequency points

scale Literal['linear', 'log']

Frequency spacing ("linear" or "log")

adaptive_tol float

Adaptive tolerance (0 disables adaptive)

adaptive_max_samples int

Maximum samples for adaptive refinement

compute_s_params bool

Whether to compute S-parameters

reference_impedance float

Reference impedance for S-params (Ohms)

excitation_port str | None

Name of port to excite (None = first port)

Methods:

Name Description
to_palace_config

Convert to Palace JSON config format.

validate_frequency_range

Validate that fmin < fmax.

to_palace_config

to_palace_config() -> dict

Convert to Palace JSON config format.

Source code in src/gsim/palace/models/problems.py
def to_palace_config(self) -> dict:
    """Convert to Palace JSON config format."""
    freq_step = (self.fmax - self.fmin) / max(1, self.num_points - 1) / 1e9
    config: dict = {
        "Samples": [
            {
                "Type": "Linear" if self.scale == "linear" else "Log",
                "MinFreq": self.fmin / 1e9,
                "MaxFreq": self.fmax / 1e9,
                "FreqStep": freq_step,
                "SaveStep": 0,
            }
        ],
        "AdaptiveTol": max(0, self.adaptive_tol),
    }
    if self.adaptive_tol > 0:
        config["AdaptiveMaxSamples"] = self.adaptive_max_samples
    return config

validate_frequency_range

validate_frequency_range() -> Self

Validate that fmin < fmax.

Source code in src/gsim/palace/models/problems.py
@model_validator(mode="after")
def validate_frequency_range(self) -> Self:
    """Validate that fmin < fmax."""
    if self.fmin >= self.fmax:
        raise ValueError(f"fmin ({self.fmin}) must be less than fmax ({self.fmax})")
    return self

DrivenSim

Bases: PalaceSimMixin, BaseModel

Frequency-domain driven simulation for S-parameter extraction.

This class configures and runs driven simulations that sweep through frequencies to compute S-parameters. Uses composition (no inheritance) with shared Geometry and Stack components from gsim.common.

Example

from gsim.palace import DrivenSim

sim = DrivenSim() sim.set_geometry(component) sim.set_stack(air_above=300.0) sim.add_cpw_port("o1", layer="topmetal2", s_width=10, gap_width=6, length=5) sim.add_cpw_port("o2", layer="topmetal2", s_width=10, gap_width=6, length=5) sim.set_driven(fmin=1e9, fmax=100e9, num_points=40) sim.set_output_dir("./sim") sim.mesh(preset="default") results = sim.run()

Attributes:

Name Type Description
geometry Geometry | None

Wrapped gdsfactory Component (from common)

stack LayerStack | None

Layer stack configuration (from common)

ports list[PortConfig]

List of single-element port configurations

cpw_ports list[CPWPortConfig]

List of CPW (two-element) port configurations

driven DrivenConfig

Driven simulation configuration (frequencies, etc.)

mesh SimulationResult

Mesh configuration

materials dict[str, MaterialConfig]

Material property overrides

numerical NumericalConfig

Numerical solver configuration

Methods:

Name Description
add_cpw_port

Add a coplanar waveguide (CPW) port.

add_port

Add a single-element lumped port.

get_status

Get the current status of this sim's cloud job.

mesh

Generate the mesh for Palace simulation.

plot_mesh

Plot the mesh using PyVista.

plot_stack

Plot the layer stack visualization.

preview

Preview the mesh without running simulation.

run

Run simulation on GDSFactory+ cloud.

run_local

Run simulation locally using Palace via Apptainer.

set_driven

Configure driven (frequency sweep) simulation.

set_geometry

Set the gdsfactory component for simulation.

set_material

Override or add material properties.

set_numerical

Configure numerical solver parameters.

set_output_dir

Set the output directory for mesh and config files.

set_stack

Configure the layer stack.

show_stack

Print the layer stack table.

start

Start cloud execution for this sim's uploaded job.

upload

Prepare config, upload to the cloud. Does NOT start execution.

validate_config

Validate the simulation configuration.

validate_mesh

Validate the generated mesh and config before cloud submission.

wait_for_results

Wait for this sim's cloud job, download and parse results.

write_config

Write Palace config.json after mesh generation.

component property

component: Component | None

Get the current component (for backward compatibility).

output_dir property

output_dir: Path | None

Get the current output directory.

add_cpw_port

add_cpw_port(
    name: str,
    *,
    layer: str,
    s_width: float,
    gap_width: float,
    length: float,
    offset: float = 0.0,
    impedance: float = 50.0,
    excited: bool = True,
) -> None

Add a coplanar waveguide (CPW) port.

CPW ports consist of two elements (upper and lower gaps) that are excited with opposite E-field directions to create the CPW mode.

Place a single gdsfactory port at the center of the signal conductor. The two gap element surfaces are computed from s_width and gap_width.

Parameters:

Name Type Description Default
name str

Port name (must match a component port at the signal center)

required
layer str

Target conductor layer (e.g., "topmetal2")

required
s_width float

Width of the signal (center) conductor (um)

required
gap_width float

Width of each gap between signal and ground (um)

required
length float

Port extent along direction (um)

required
offset float

Shift the port inward along the waveguide (um). Positive moves away from the boundary, into the conductor.

0.0
impedance float

Port impedance (Ohms)

50.0
excited bool

Whether this port is excited

True
Example

sim.add_cpw_port( ... "left", layer="topmetal2", s_width=20, gap_width=15, length=5.0 ... )

Source code in src/gsim/palace/driven.py
def add_cpw_port(
    self,
    name: str,
    *,
    layer: str,
    s_width: float,
    gap_width: float,
    length: float,
    offset: float = 0.0,
    impedance: float = 50.0,
    excited: bool = True,
) -> None:
    """Add a coplanar waveguide (CPW) port.

    CPW ports consist of two elements (upper and lower gaps) that are
    excited with opposite E-field directions to create the CPW mode.

    Place a single gdsfactory port at the center of the signal conductor.
    The two gap element surfaces are computed from s_width and gap_width.

    Args:
        name: Port name (must match a component port at the signal center)
        layer: Target conductor layer (e.g., "topmetal2")
        s_width: Width of the signal (center) conductor (um)
        gap_width: Width of each gap between signal and ground (um)
        length: Port extent along direction (um)
        offset: Shift the port inward along the waveguide (um).
            Positive moves away from the boundary, into the conductor.
        impedance: Port impedance (Ohms)
        excited: Whether this port is excited

    Example:
        >>> sim.add_cpw_port(
        ...     "left", layer="topmetal2", s_width=20, gap_width=15, length=5.0
        ... )
    """
    # Remove existing CPW port with same name if any
    self.cpw_ports = [p for p in self.cpw_ports if p.name != name]

    self.cpw_ports.append(
        CPWPortConfig(
            name=name,
            layer=layer,
            s_width=s_width,
            gap_width=gap_width,
            length=length,
            offset=offset,
            impedance=impedance,
            excited=excited,
        )
    )

add_port

add_port(
    name: str,
    *,
    layer: str | None = None,
    from_layer: str | None = None,
    to_layer: str | None = None,
    length: float | None = None,
    impedance: float = 50.0,
    resistance: float | None = None,
    inductance: float | None = None,
    capacitance: float | None = None,
    excited: bool = True,
    geometry: Literal["inplane", "via"] = "inplane",
) -> None

Add a single-element lumped port.

Parameters:

Name Type Description Default
name str

Port name (must match component port name)

required
layer str | None

Target layer for inplane ports

None
from_layer str | None

Bottom layer for via ports

None
to_layer str | None

Top layer for via ports

None
length float | None

Port extent along direction (um)

None
impedance float

Port impedance (Ohms)

50.0
resistance float | None

Series resistance (Ohms)

None
inductance float | None

Series inductance (H)

None
capacitance float | None

Shunt capacitance (F)

None
excited bool

Whether this port is excited

True
geometry Literal['inplane', 'via']

Port geometry type ("inplane" or "via")

'inplane'
Example

sim.add_port("o1", layer="topmetal2", length=5.0) sim.add_port( ... "feed", from_layer="metal1", to_layer="topmetal2", geometry="via" ... )

Source code in src/gsim/palace/driven.py
def add_port(
    self,
    name: str,
    *,
    layer: str | None = None,
    from_layer: str | None = None,
    to_layer: str | None = None,
    length: float | None = None,
    impedance: float = 50.0,
    resistance: float | None = None,
    inductance: float | None = None,
    capacitance: float | None = None,
    excited: bool = True,
    geometry: Literal["inplane", "via"] = "inplane",
) -> None:
    """Add a single-element lumped port.

    Args:
        name: Port name (must match component port name)
        layer: Target layer for inplane ports
        from_layer: Bottom layer for via ports
        to_layer: Top layer for via ports
        length: Port extent along direction (um)
        impedance: Port impedance (Ohms)
        resistance: Series resistance (Ohms)
        inductance: Series inductance (H)
        capacitance: Shunt capacitance (F)
        excited: Whether this port is excited
        geometry: Port geometry type ("inplane" or "via")

    Example:
        >>> sim.add_port("o1", layer="topmetal2", length=5.0)
        >>> sim.add_port(
        ...     "feed", from_layer="metal1", to_layer="topmetal2", geometry="via"
        ... )
    """
    # Remove existing config for this port if any
    self.ports = [p for p in self.ports if p.name != name]

    self.ports.append(
        PortConfig(
            name=name,
            layer=layer,
            from_layer=from_layer,
            to_layer=to_layer,
            length=length,
            impedance=impedance,
            resistance=resistance,
            inductance=inductance,
            capacitance=capacitance,
            excited=excited,
            geometry=geometry,
        )
    )

get_status

get_status() -> str

Get the current status of this sim's cloud job.

Returns:

Type Description
str

Status string ("created", "queued", "running",

str

"completed", "failed").

Raises:

Type Description
ValueError

If no job has been submitted yet.

Source code in src/gsim/palace/driven.py
def get_status(self) -> str:
    """Get the current status of this sim's cloud job.

    Returns:
        Status string (``"created"``, ``"queued"``, ``"running"``,
        ``"completed"``, ``"failed"``).

    Raises:
        ValueError: If no job has been submitted yet.
    """
    from gsim import gcloud

    if self._job_id is None:
        raise ValueError("No job submitted yet")
    return gcloud.get_status(self._job_id)

mesh

mesh(
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = False,
    model_name: str = "palace",
    verbose: bool = True,
) -> SimulationResult

Generate the mesh for Palace simulation.

Only generates the mesh file (palace.msh). Config is generated separately with write_config().

Requires set_output_dir() to be called first.

Parameters:

Name Type Description Default
preset Literal['coarse', 'default', 'graded', 'fine'] | None

Mesh quality preset ("coarse", "default", "graded", "fine")

None
refined_mesh_size float | None

Mesh size near conductors (um), overrides preset

None
max_mesh_size float | None

Max mesh size in air/dielectric (um), overrides preset

None
margin float | None

XY margin around design (um), overrides preset

None
airbox_margin float | None

Extra airbox around stack (um); 0 = disabled

None
fmax float | None

Max frequency for mesh sizing (Hz), overrides preset

None
planar_conductors bool | None

Treat conductors as 2D PEC surfaces

None
show_gui bool

Show gmsh GUI during meshing

False
model_name str

Base name for output files

'palace'
verbose bool

Print progress messages

True

Returns:

Type Description
SimulationResult

SimulationResult with mesh path

Raises:

Type Description
ValueError

If output_dir not set or configuration is invalid

Example

sim.set_output_dir("./sim") result = sim.mesh(preset="fine", planar_conductors=True) print(f"Mesh saved to: {result.mesh_path}")

Source code in src/gsim/palace/driven.py
def mesh(
    self,
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = False,
    model_name: str = "palace",
    verbose: bool = True,
) -> SimulationResult:
    """Generate the mesh for Palace simulation.

    Only generates the mesh file (palace.msh). Config is generated
    separately with write_config().

    Requires set_output_dir() to be called first.

    Args:
        preset: Mesh quality preset ("coarse", "default", "graded", "fine")
        refined_mesh_size: Mesh size near conductors (um), overrides preset
        max_mesh_size: Max mesh size in air/dielectric (um), overrides preset
        margin: XY margin around design (um), overrides preset
        airbox_margin: Extra airbox around stack (um); 0 = disabled
        fmax: Max frequency for mesh sizing (Hz), overrides preset
        planar_conductors: Treat conductors as 2D PEC surfaces
        show_gui: Show gmsh GUI during meshing
        model_name: Base name for output files
        verbose: Print progress messages

    Returns:
        SimulationResult with mesh path

    Raises:
        ValueError: If output_dir not set or configuration is invalid

    Example:
        >>> sim.set_output_dir("./sim")
        >>> result = sim.mesh(preset="fine", planar_conductors=True)
        >>> print(f"Mesh saved to: {result.mesh_path}")
    """
    from gsim.palace.ports import extract_ports

    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    component = self.geometry.component if self.geometry else None

    # Build mesh config
    mesh_config = self._build_mesh_config(
        preset=preset,
        refined_mesh_size=refined_mesh_size,
        max_mesh_size=max_mesh_size,
        margin=margin,
        airbox_margin=airbox_margin,
        fmax=fmax,
        planar_conductors=planar_conductors,
        show_gui=show_gui,
    )

    # Validate configuration
    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    output_dir = self._output_dir

    # Resolve stack and configure ports
    stack = self._resolve_stack()
    self._configure_ports_on_component(stack)

    # Extract ports
    palace_ports = extract_ports(component, stack)

    # Generate mesh (config is written separately by simulate() or write_config())
    return self._generate_mesh_internal(
        output_dir=output_dir,
        mesh_config=mesh_config,
        ports=palace_ports,
        driven_config=self.driven,
        model_name=model_name,
        verbose=verbose,
        write_config=False,
    )

plot_mesh

plot_mesh(
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None

Plot the mesh using PyVista.

Requires mesh() to be called first.

Parameters:

Name Type Description Default
output str | Path | None

Output PNG path (only used if interactive=False)

None
show_groups list[str] | None

List of group name patterns to show (None = all). Example: ["metal", "P"] to show metal layers and ports.

None
interactive bool

If True, open interactive 3D viewer. If False, save static PNG to output path.

True
style Literal['wireframe', 'solid']

"wireframe" (edges only) or "solid" (coloured surfaces per physical group).

'wireframe'
transparent_groups list[str] | None

Group names rendered at low opacity in solid mode. Ignored in wireframe mode.

None

Raises:

Type Description
ValueError

If output_dir not set or mesh file doesn't exist

Example

sim.mesh(preset="default") sim.plot_mesh(show_groups=["metal", "P"]) sim.plot_mesh(style="solid", transparent_groups=["Absorbing_boundary"])

Source code in src/gsim/palace/base.py
def plot_mesh(
    self,
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None:
    """Plot the mesh using PyVista.

    Requires mesh() to be called first.

    Args:
        output: Output PNG path (only used if interactive=False)
        show_groups: List of group name patterns to show (None = all).
            Example: ["metal", "P"] to show metal layers and ports.
        interactive: If True, open interactive 3D viewer.
            If False, save static PNG to output path.
        style: ``"wireframe"`` (edges only) or ``"solid"`` (coloured
            surfaces per physical group).
        transparent_groups: Group names rendered at low opacity in
            *solid* mode.  Ignored in *wireframe* mode.

    Raises:
        ValueError: If output_dir not set or mesh file doesn't exist

    Example:
        >>> sim.mesh(preset="default")
        >>> sim.plot_mesh(show_groups=["metal", "P"])
        >>> sim.plot_mesh(style="solid", transparent_groups=["Absorbing_boundary"])
    """
    from gsim.viz import plot_mesh as _plot_mesh

    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    mesh_path = self._output_dir / "palace.msh"
    if not mesh_path.exists():
        raise ValueError(f"Mesh file not found: {mesh_path}. Call mesh() first.")

    # Default output path if not interactive
    if output is None and not interactive:
        output = self._output_dir / "mesh.png"

    _plot_mesh(
        msh_path=mesh_path,
        output=output,
        show_groups=show_groups,
        interactive=interactive,
        style=style,
        transparent_groups=transparent_groups,
    )

plot_stack

plot_stack() -> None

Plot the layer stack visualization.

Example

sim.plot_stack()

Source code in src/gsim/palace/base.py
def plot_stack(self) -> None:
    """Plot the layer stack visualization.

    Example:
        >>> sim.plot_stack()
    """
    from gsim.common.stack import plot_stack

    if self.stack is None:
        self._resolve_stack()

    if self.stack is not None:
        plot_stack(self.stack)

preview

preview(
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = True,
) -> None

Preview the mesh without running simulation.

Opens the gmsh GUI to visualize the mesh interactively.

Parameters:

Name Type Description Default
preset Literal['coarse', 'default', 'graded', 'fine'] | None

Mesh quality preset ("coarse", "default", "graded", "fine")

None
refined_mesh_size float | None

Mesh size near conductors (um)

None
max_mesh_size float | None

Max mesh size in air/dielectric (um)

None
margin float | None

XY margin around design (um)

None
airbox_margin float | None

Extra airbox around stack (um); 0 = disabled

None
fmax float | None

Max frequency for mesh sizing (Hz)

None
planar_conductors bool | None

Treat conductors as 2D PEC surfaces

None
show_gui bool

Show gmsh GUI for interactive preview

True
Example

sim.preview(preset="fine", planar_conductors=True, show_gui=True)

Source code in src/gsim/palace/driven.py
def preview(
    self,
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = True,
) -> None:
    """Preview the mesh without running simulation.

    Opens the gmsh GUI to visualize the mesh interactively.

    Args:
        preset: Mesh quality preset ("coarse", "default", "graded", "fine")
        refined_mesh_size: Mesh size near conductors (um)
        max_mesh_size: Max mesh size in air/dielectric (um)
        margin: XY margin around design (um)
        airbox_margin: Extra airbox around stack (um); 0 = disabled
        fmax: Max frequency for mesh sizing (Hz)
        planar_conductors: Treat conductors as 2D PEC surfaces
        show_gui: Show gmsh GUI for interactive preview

    Example:
        >>> sim.preview(preset="fine", planar_conductors=True, show_gui=True)
    """
    from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
    from gsim.palace.mesh import generate_mesh

    component = self.geometry.component if self.geometry else None

    # Validate configuration
    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    # Build mesh config
    mesh_config = self._build_mesh_config(
        preset=preset,
        refined_mesh_size=refined_mesh_size,
        max_mesh_size=max_mesh_size,
        margin=margin,
        airbox_margin=airbox_margin,
        fmax=fmax,
        planar_conductors=planar_conductors,
        show_gui=show_gui,
    )

    # Resolve stack
    stack = self._resolve_stack()

    # Get ports
    ports = self._get_ports_for_preview(stack)

    # Build legacy mesh config with preview mode
    legacy_mesh_config = LegacyMeshConfig(
        refined_mesh_size=mesh_config.refined_mesh_size,
        max_mesh_size=mesh_config.max_mesh_size,
        cells_per_wavelength=mesh_config.cells_per_wavelength,
        margin=mesh_config.margin,
        airbox_margin=mesh_config.airbox_margin,
        fmax=mesh_config.fmax,
        show_gui=show_gui,
        preview_only=True,
        planar_conductors=mesh_config.planar_conductors,
        refine_from_curves=mesh_config.refine_from_curves,
    )

    # Generate mesh in temp directory
    with tempfile.TemporaryDirectory() as tmpdir:
        generate_mesh(
            component=component,
            stack=stack,
            ports=ports,
            output_dir=tmpdir,
            config=legacy_mesh_config,
        )

run

run(
    parent_dir: str | Path | None = None, *, verbose: bool = True, wait: bool = True
) -> dict[str, Path] | str

Run simulation on GDSFactory+ cloud.

Requires mesh() to be called first. Automatically calls write_config() if config.json hasn't been written yet.

Parameters:

Name Type Description Default
parent_dir str | Path | None

Where to create the sim directory. Defaults to the current working directory.

None
verbose bool

Print progress messages.

True
wait bool

If True (default), block until results are ready. If False, upload + start and return the job_id.

True

Returns:

Type Description
dict[str, Path] | str

dict[str, Path] of output files when wait=True,

dict[str, Path] | str

or job_id string when wait=False.

Raises:

Type Description
ValueError

If output_dir not set or mesh not generated

RuntimeError

If simulation fails

Example

results = sim.run() print(f"S-params saved to: {results['port-S.csv']}")

Source code in src/gsim/palace/driven.py
def run(
    self,
    parent_dir: str | Path | None = None,
    *,
    verbose: bool = True,
    wait: bool = True,
) -> dict[str, Path] | str:
    """Run simulation on GDSFactory+ cloud.

    Requires mesh() to be called first. Automatically calls
    write_config() if config.json hasn't been written yet.

    Args:
        parent_dir: Where to create the sim directory.
            Defaults to the current working directory.
        verbose: Print progress messages.
        wait: If ``True`` (default), block until results are ready.
            If ``False``, upload + start and return the ``job_id``.

    Returns:
        ``dict[str, Path]`` of output files when ``wait=True``,
        or ``job_id`` string when ``wait=False``.

    Raises:
        ValueError: If output_dir not set or mesh not generated
        RuntimeError: If simulation fails

    Example:
        >>> results = sim.run()
        >>> print(f"S-params saved to: {results['port-S.csv']}")
    """
    self.upload(verbose=False)
    self.start(verbose=verbose)
    if not wait:
        return self._job_id  # type: ignore[return-value]  # set by upload()
    return self.wait_for_results(verbose=verbose, parent_dir=parent_dir)

run_local

run_local(
    *,
    palace_sif_path: str | Path | None = None,
    num_processes: int | None = None,
    verbose: bool = True,
) -> dict[str, Path]

Run simulation locally using Palace via Apptainer.

Requires mesh() and write_config() to be called first, and Palace to be installed locally via Apptainer.

Parameters:

Name Type Description Default
palace_sif_path str | Path | None

Path to Palace Apptainer SIF file. If None, uses PALACE_SIF environment variable.

None
num_processes int | None

Number of MPI processes (default: CPU count - 2)

None
verbose bool

Print progress messages

True

Returns:

Type Description
dict[str, Path]

Dict mapping result filenames to local paths

Raises:

Type Description
ValueError

If output_dir not set or PALACE_SIF not configured

FileNotFoundError

If mesh, config, or Palace SIF not found

RuntimeError

If simulation fails

Example

Using environment variable

import os os.environ["PALACE_SIF"] = "/path/to/Palace.sif" results = sim.simulate_local()

Or specify path directly

results = sim.simulate_local(palace_sif_path="/path/to/Palace.sif") print(f"S-params: {results['port-S.csv']}")

Source code in src/gsim/palace/driven.py
def run_local(
    self,
    *,
    palace_sif_path: str | Path | None = None,
    num_processes: int | None = None,
    verbose: bool = True,
) -> dict[str, Path]:
    """Run simulation locally using Palace via Apptainer.

    Requires mesh() and write_config() to be called first,
    and Palace to be installed locally via Apptainer.

    Args:
        palace_sif_path: Path to Palace Apptainer SIF file.
            If None, uses PALACE_SIF environment variable.
        num_processes: Number of MPI processes (default: CPU count - 2)
        verbose: Print progress messages

    Returns:
        Dict mapping result filenames to local paths

    Raises:
        ValueError: If output_dir not set or PALACE_SIF not configured
        FileNotFoundError: If mesh, config, or Palace SIF not found
        RuntimeError: If simulation fails

    Example:
        >>> # Using environment variable
        >>> import os
        >>> os.environ["PALACE_SIF"] = "/path/to/Palace.sif"
        >>> results = sim.simulate_local()
        >>>
        >>> # Or specify path directly
        >>> results = sim.simulate_local(palace_sif_path="/path/to/Palace.sif")
        >>> print(f"S-params: {results['port-S.csv']}")
    """
    import os
    import subprocess

    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    output_dir = Path(self._output_dir)
    config_path = output_dir / "config.json"
    mesh_path = output_dir / "palace.msh"

    # Check required files exist
    if not config_path.exists():
        raise FileNotFoundError(
            f"Config file not found: {config_path}. Call write_config() first."
        )

    if not mesh_path.exists():
        raise FileNotFoundError(
            f"Mesh file not found: {mesh_path}. Call mesh() first."
        )

    # Determine Palace SIF path from environment variable or parameter
    if palace_sif_path is None:
        palace_sif_path = os.environ.get("PALACE_SIF")
        if palace_sif_path is None:
            raise ValueError(
                "Palace SIF path not specified. Either set PALACE_SIF "
                "environment variable or pass palace_sif_path parameter."
            )
        if verbose:
            logger.info("Using PALACE_SIF from environment: %s", palace_sif_path)

    sif_path = Path(palace_sif_path).expanduser().resolve()

    if not sif_path.exists():
        raise FileNotFoundError(
            f"Palace SIF file not found: {sif_path}. "
            "Install Palace via Apptainer or provide correct path."
        )

    # Determine number of processes
    if num_processes is None:
        try:
            import psutil

            num_processes = psutil.cpu_count(logical=True) or 1
        except ImportError:
            import os

            num_processes = os.cpu_count() or 1

    # Build command
    cmd = [
        "apptainer",
        "run",
        str(sif_path),
        "-nt",
        str(num_processes),
        "config.json",
    ]

    if verbose:
        logger.info("Running Palace simulation in %s", output_dir)
        logger.info("Command: %s", " ".join(cmd))
        logger.info("Processes: %d", num_processes)

    # Run simulation
    try:
        result = subprocess.run(  # noqa: S603
            cmd,
            cwd=output_dir,
            check=True,
            capture_output=True,
            text=True,
        )

        # Log output if verbose
        if verbose and result.stdout:
            logger.info(result.stdout)
        if verbose and result.stderr:
            logger.warning(result.stderr)

    except subprocess.CalledProcessError as e:
        error_msg = f"Palace simulation failed with return code {e.returncode}"
        if e.stdout:
            error_msg += f"\n\nStdout:\n{e.stdout}"
        if e.stderr:
            error_msg += f"\n\nStderr:\n{e.stderr}"
        raise RuntimeError(error_msg) from e
    except FileNotFoundError as e:
        raise RuntimeError(
            "Apptainer not found. Install Apptainer to run local simulations."
        ) from e

    if verbose:
        logger.info("Simulation completed successfully")

    postpro_dir = output_dir / "output/palace/"

    if verbose:
        logger.info("Results saved to %s", postpro_dir)

    return {
        file.name: file
        for file in postpro_dir.iterdir()
        if file.is_file() and not file.name.startswith(".")
    }

set_driven

set_driven(
    *,
    fmin: float = 1000000000.0,
    fmax: float = 100000000000.0,
    num_points: int = 40,
    scale: Literal["linear", "log"] = "linear",
    adaptive_tol: float = 0.02,
    adaptive_max_samples: int = 20,
    compute_s_params: bool = True,
    reference_impedance: float = 50.0,
    excitation_port: str | None = None,
) -> None

Configure driven (frequency sweep) simulation.

Parameters:

Name Type Description Default
fmin float

Minimum frequency in Hz

1000000000.0
fmax float

Maximum frequency in Hz

100000000000.0
num_points int

Number of frequency points

40
scale Literal['linear', 'log']

"linear" or "log" frequency spacing

'linear'
adaptive_tol float

Adaptive frequency tolerance (0 disables adaptive)

0.02
adaptive_max_samples int

Max samples for adaptive refinement

20
compute_s_params bool

Compute S-parameters

True
reference_impedance float

Reference impedance for S-params (Ohms)

50.0
excitation_port str | None

Port to excite (None = first port)

None
Example

sim.set_driven(fmin=1e9, fmax=100e9, num_points=40)

Source code in src/gsim/palace/driven.py
def set_driven(
    self,
    *,
    fmin: float = 1e9,
    fmax: float = 100e9,
    num_points: int = 40,
    scale: Literal["linear", "log"] = "linear",
    adaptive_tol: float = 0.02,
    adaptive_max_samples: int = 20,
    compute_s_params: bool = True,
    reference_impedance: float = 50.0,
    excitation_port: str | None = None,
) -> None:
    """Configure driven (frequency sweep) simulation.

    Args:
        fmin: Minimum frequency in Hz
        fmax: Maximum frequency in Hz
        num_points: Number of frequency points
        scale: "linear" or "log" frequency spacing
        adaptive_tol: Adaptive frequency tolerance (0 disables adaptive)
        adaptive_max_samples: Max samples for adaptive refinement
        compute_s_params: Compute S-parameters
        reference_impedance: Reference impedance for S-params (Ohms)
        excitation_port: Port to excite (None = first port)

    Example:
        >>> sim.set_driven(fmin=1e9, fmax=100e9, num_points=40)
    """
    self.driven = DrivenConfig(
        fmin=fmin,
        fmax=fmax,
        num_points=num_points,
        scale=scale,
        adaptive_tol=adaptive_tol,
        adaptive_max_samples=adaptive_max_samples,
        compute_s_params=compute_s_params,
        reference_impedance=reference_impedance,
        excitation_port=excitation_port,
    )

set_geometry

set_geometry(component: Component) -> None

Set the gdsfactory component for simulation.

Parameters:

Name Type Description Default
component Component

gdsfactory Component to simulate

required
Example

sim.set_geometry(my_component)

Source code in src/gsim/palace/base.py
def set_geometry(self, component: Component) -> None:
    """Set the gdsfactory component for simulation.

    Args:
        component: gdsfactory Component to simulate

    Example:
        >>> sim.set_geometry(my_component)
    """
    from gsim.common import Geometry

    self.geometry = Geometry(component=component)

set_material

set_material(
    name: str,
    *,
    material_type: Literal["conductor", "dielectric", "semiconductor"] | None = None,
    conductivity: float | None = None,
    permittivity: float | None = None,
    loss_tangent: float | None = None,
) -> None

Override or add material properties.

Parameters:

Name Type Description Default
name str

Material name

required
material_type Literal['conductor', 'dielectric', 'semiconductor'] | None

Material type (conductor, dielectric, semiconductor)

None
conductivity float | None

Conductivity in S/m (for conductors)

None
permittivity float | None

Relative permittivity (for dielectrics)

None
loss_tangent float | None

Dielectric loss tangent

None
Example

sim.set_material( ... "aluminum", material_type="conductor", conductivity=3.8e7 ... ) sim.set_material("sio2", material_type="dielectric", permittivity=3.9)

Source code in src/gsim/palace/base.py
def set_material(
    self,
    name: str,
    *,
    material_type: Literal["conductor", "dielectric", "semiconductor"]
    | None = None,
    conductivity: float | None = None,
    permittivity: float | None = None,
    loss_tangent: float | None = None,
) -> None:
    """Override or add material properties.

    Args:
        name: Material name
        material_type: Material type (conductor, dielectric, semiconductor)
        conductivity: Conductivity in S/m (for conductors)
        permittivity: Relative permittivity (for dielectrics)
        loss_tangent: Dielectric loss tangent

    Example:
        >>> sim.set_material(
        ...     "aluminum", material_type="conductor", conductivity=3.8e7
        ... )
        >>> sim.set_material("sio2", material_type="dielectric", permittivity=3.9)
    """
    from gsim.palace.models import MaterialConfig

    # Determine type if not provided
    resolved_type = material_type
    if resolved_type is None:
        if conductivity is not None and conductivity > 1e4:
            resolved_type = "conductor"
        elif permittivity is not None:
            resolved_type = "dielectric"
        else:
            resolved_type = "dielectric"

    self.materials[name] = MaterialConfig(
        type=resolved_type,
        conductivity=conductivity,
        permittivity=permittivity,
        loss_tangent=loss_tangent,
    )

set_numerical

set_numerical(
    *,
    order: int = 1,
    tolerance: float = 1e-06,
    max_iterations: int = 400,
    solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
    preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
    device: Literal["CPU", "GPU"] = "CPU",
    num_processors: int | None = None,
) -> None

Configure numerical solver parameters.

Parameters:

Name Type Description Default
order int

Finite element order (1-4)

1
tolerance float

Linear solver tolerance

1e-06
max_iterations int

Maximum solver iterations

400
solver_type Literal['Default', 'SuperLU', 'STRUMPACK', 'MUMPS']

Linear solver type

'Default'
preconditioner Literal['Default', 'AMS', 'BoomerAMG']

Preconditioner type

'Default'
device Literal['CPU', 'GPU']

Compute device (CPU or GPU)

'CPU'
num_processors int | None

Number of processors (None = auto)

None
Example

sim.set_numerical(order=3, tolerance=1e-8)

Source code in src/gsim/palace/base.py
def set_numerical(
    self,
    *,
    order: int = 1,
    tolerance: float = 1e-6,
    max_iterations: int = 400,
    solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
    preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
    device: Literal["CPU", "GPU"] = "CPU",
    num_processors: int | None = None,
) -> None:
    """Configure numerical solver parameters.

    Args:
        order: Finite element order (1-4)
        tolerance: Linear solver tolerance
        max_iterations: Maximum solver iterations
        solver_type: Linear solver type
        preconditioner: Preconditioner type
        device: Compute device (CPU or GPU)
        num_processors: Number of processors (None = auto)

    Example:
        >>> sim.set_numerical(order=3, tolerance=1e-8)
    """
    from gsim.palace.models import NumericalConfig

    self.numerical = NumericalConfig(
        order=order,
        tolerance=tolerance,
        max_iterations=max_iterations,
        solver_type=solver_type,
        preconditioner=preconditioner,
        device=device,
        num_processors=num_processors,
    )

set_output_dir

set_output_dir(path: str | Path) -> None

Set the output directory for mesh and config files.

Parameters:

Name Type Description Default
path str | Path

Directory path for output files

required
Example

sim.set_output_dir("./palace-sim")

Source code in src/gsim/palace/base.py
def set_output_dir(self, path: str | Path) -> None:
    """Set the output directory for mesh and config files.

    Args:
        path: Directory path for output files

    Example:
        >>> sim.set_output_dir("./palace-sim")
    """
    self._output_dir = Path(path)
    self._output_dir.mkdir(parents=True, exist_ok=True)

set_stack

set_stack(
    stack: LayerStack | None = None,
    *,
    yaml_path: str | Path | None = None,
    air_above: float = 200.0,
    substrate_thickness: float = 2.0,
    include_substrate: bool = False,
    **kwargs,
) -> None

Configure the layer stack.

Three modes of use:

  1. Active PDK (default — auto-detects IHP, QPDK, etc.)::

    sim.set_stack(air_above=300.0, substrate_thickness=2.0)

  2. YAML file::

    sim.set_stack(yaml_path="custom_stack.yaml")

  3. Custom stack (advanced — pass a hand-built LayerStack)::

    sim.set_stack(my_layer_stack)

Parameters:

Name Type Description Default
stack LayerStack | None

Custom gsim LayerStack (bypasses PDK extraction).

None
yaml_path str | Path | None

Path to custom YAML stack file.

None
air_above float

Air box height above top metal in um.

200.0
substrate_thickness float

Thickness below z=0 in um.

2.0
include_substrate bool

Include lossy silicon substrate.

False
**kwargs

Additional args passed to extract_layer_stack.

{}
Example

sim.set_stack(air_above=300.0, substrate_thickness=2.0)

Source code in src/gsim/palace/base.py
def set_stack(
    self,
    stack: LayerStack | None = None,
    *,
    yaml_path: str | Path | None = None,
    air_above: float = 200.0,
    substrate_thickness: float = 2.0,
    include_substrate: bool = False,
    **kwargs,
) -> None:
    """Configure the layer stack.

    Three modes of use:

    1. **Active PDK** (default — auto-detects IHP, QPDK, etc.)::

           sim.set_stack(air_above=300.0, substrate_thickness=2.0)

    2. **YAML file**::

           sim.set_stack(yaml_path="custom_stack.yaml")

    3. **Custom stack** (advanced — pass a hand-built LayerStack)::

           sim.set_stack(my_layer_stack)

    Args:
        stack: Custom gsim LayerStack (bypasses PDK extraction).
        yaml_path: Path to custom YAML stack file.
        air_above: Air box height above top metal in um.
        substrate_thickness: Thickness below z=0 in um.
        include_substrate: Include lossy silicon substrate.
        **kwargs: Additional args passed to extract_layer_stack.

    Example:
        >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0)
    """
    if stack is not None:
        # Directly use a pre-built LayerStack — skip lazy resolution
        self.stack = stack
        self._stack_kwargs = {"_prebuilt": True}
        return

    self._stack_kwargs = {
        "yaml_path": yaml_path,
        "air_above": air_above,
        "substrate_thickness": substrate_thickness,
        "include_substrate": include_substrate,
        **kwargs,
    }
    # Stack will be resolved lazily during mesh() or simulate()
    self.stack = None

show_stack

show_stack() -> None

Print the layer stack table.

Example

sim.show_stack()

Source code in src/gsim/palace/base.py
def show_stack(self) -> None:
    """Print the layer stack table.

    Example:
        >>> sim.show_stack()
    """
    from gsim.common.stack import print_stack_table

    if self.stack is None:
        self._resolve_stack()

    if self.stack is not None:
        print_stack_table(self.stack)

start

start(*, verbose: bool = True) -> None

Start cloud execution for this sim's uploaded job.

Raises:

Type Description
ValueError

If :meth:upload has not been called.

Source code in src/gsim/palace/driven.py
def start(self, *, verbose: bool = True) -> None:
    """Start cloud execution for this sim's uploaded job.

    Raises:
        ValueError: If :meth:`upload` has not been called.
    """
    from gsim import gcloud

    if self._job_id is None:
        raise ValueError("Call upload() first")
    gcloud.start(self._job_id, verbose=verbose)

upload

upload(*, verbose: bool = True) -> str

Prepare config, upload to the cloud. Does NOT start execution.

Requires :meth:set_output_dir and :meth:mesh to have been called first.

Parameters:

Name Type Description Default
verbose bool

Print progress messages.

True

Returns:

Name Type Description
str

job_id string for use with :meth:start, :meth:get_status,

or str

func:gsim.wait_for_results.

Source code in src/gsim/palace/driven.py
def upload(self, *, verbose: bool = True) -> str:
    """Prepare config, upload to the cloud. Does NOT start execution.

    Requires :meth:`set_output_dir` and :meth:`mesh` to have been
    called first.

    Args:
        verbose: Print progress messages.

    Returns:
        ``job_id`` string for use with :meth:`start`, :meth:`get_status`,
        or :func:`gsim.wait_for_results`.
    """
    from gsim import gcloud

    tmp = self._prepare_upload_dir()
    try:
        self._job_id = gcloud.upload(tmp, "palace", verbose=verbose)
    except Exception:
        import shutil

        shutil.rmtree(tmp, ignore_errors=True)
        raise
    return self._job_id

validate_config

validate_config() -> ValidationResult

Validate the simulation configuration.

Returns:

Type Description
ValidationResult

ValidationResult with validation status and messages

Source code in src/gsim/palace/driven.py
def validate_config(self) -> ValidationResult:
    """Validate the simulation configuration.

    Returns:
        ValidationResult with validation status and messages
    """
    errors = []
    warnings_list = []

    # Check geometry
    if self.geometry is None:
        errors.append("No component set. Call set_geometry(component) first.")

    # Check stack
    if self.stack is None and not self._stack_kwargs:
        warnings_list.append(
            "No stack configured. Will use active PDK with defaults."
        )

    # Check ports
    has_ports = bool(self.ports) or bool(self.cpw_ports)
    if not has_ports:
        warnings_list.append(
            "No ports configured. Call add_port() or add_cpw_port()."
        )
    else:
        # Validate port configurations
        for port in self.ports:
            if port.geometry == "inplane" and port.layer is None:
                errors.append(f"Port '{port.name}': inplane ports require 'layer'")
            if port.geometry == "via" and (
                port.from_layer is None or port.to_layer is None
            ):
                errors.append(
                    f"Port '{port.name}': via ports require "
                    "'from_layer' and 'to_layer'"
                )

        # Validate CPW ports
        errors.extend(
            f"CPW port '{cpw.name}': 'layer' is required"
            for cpw in self.cpw_ports
            if not cpw.layer
        )

    # Validate excitation port if specified
    if self.driven.excitation_port is not None:
        port_names = [p.name for p in self.ports]
        cpw_names = [cpw.name for cpw in self.cpw_ports]
        all_port_names = port_names + cpw_names
        if self.driven.excitation_port not in all_port_names:
            errors.append(
                f"Excitation port '{self.driven.excitation_port}' not found. "
                f"Available: {all_port_names}"
            )

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

validate_mesh

validate_mesh() -> ValidationResult

Validate the generated mesh and config before cloud submission.

Checks that physical groups are correctly assigned after meshing: conductor surfaces, dielectric volumes, ports, and absorbing boundary. Also verifies the generated config.json structure.

Call after mesh() and before run().

Returns:

Type Description
ValidationResult

ValidationResult with validation status and messages

Example

sim.mesh(preset="coarse") result = sim.validate_mesh() print(result)

Source code in src/gsim/palace/base.py
def validate_mesh(self) -> ValidationResult:
    """Validate the generated mesh and config before cloud submission.

    Checks that physical groups are correctly assigned after meshing:
    conductor surfaces, dielectric volumes, ports, and absorbing boundary.
    Also verifies the generated config.json structure.

    Call after mesh() and before run().

    Returns:
        ValidationResult with validation status and messages

    Example:
        >>> sim.mesh(preset="coarse")
        >>> result = sim.validate_mesh()
        >>> print(result)
    """
    errors = []
    warnings_list = []

    mesh_result = getattr(self, "_mesh_result", None) or getattr(
        self, "_last_mesh_result", None
    )
    if mesh_result is None:
        errors.append("No mesh generated. Call mesh() first.")
        return ValidationResult(valid=False, errors=errors, warnings=warnings_list)

    groups = mesh_result.groups

    # Check dielectric volumes
    if not groups.get("volumes"):
        errors.append("No dielectric volumes in mesh.")
    else:
        vol_names = list(groups["volumes"].keys())
        warnings_list.append(f"Volumes: {vol_names}")

    # Check conductor surfaces (volumetric or PEC)
    has_conductors = bool(groups.get("conductor_surfaces"))
    has_pec = bool(groups.get("pec_surfaces"))
    if not has_conductors and not has_pec:
        errors.append(
            "No conductor surfaces in mesh. "
            "Check that conductor layers have polygons and correct layer_type."
        )
    else:
        if has_conductors:
            warnings_list.append(
                f"Conductor surfaces: {list(groups['conductor_surfaces'].keys())}"
            )
        if has_pec:
            warnings_list.append(
                f"PEC surfaces: {list(groups['pec_surfaces'].keys())}"
            )

    # Check ports
    port_surfaces = groups.get("port_surfaces", {})
    if not port_surfaces:
        errors.append("No port surfaces in mesh.")
    else:
        for port_name, port_info in port_surfaces.items():
            if port_info.get("type") == "cpw":
                n_elems = len(port_info.get("elements", []))
                if n_elems < 2:
                    errors.append(
                        f"CPW port '{port_name}' has {n_elems} elements "
                        f"(expected >= 2)."
                    )

    # Check absorbing boundary
    if not groups.get("boundary_surfaces", {}).get("absorbing"):
        warnings_list.append(
            "No absorbing boundary found. This is expected if airbox_margin=0."
        )

    # Validate config.json if it exists
    output_dir = getattr(self, "_output_dir", None)
    if output_dir is not None:
        import json

        config_path = output_dir / "config.json"
        if config_path.exists():
            try:
                config = json.loads(config_path.read_text())
                boundaries = config.get("Boundaries", {})
                if not boundaries.get("Conductivity") and not boundaries.get("PEC"):
                    errors.append(
                        "config.json has no Conductivity or PEC boundaries."
                    )
                if not boundaries.get("LumpedPort"):
                    errors.append("config.json has no LumpedPort entries.")
            except json.JSONDecodeError as e:
                errors.append(f"config.json is invalid JSON: {e}")

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

wait_for_results

wait_for_results(*, verbose: bool = True, parent_dir: str | Path | None = None) -> Any

Wait for this sim's cloud job, download and parse results.

Parameters:

Name Type Description Default
verbose bool

Print progress messages.

True
parent_dir str | Path | None

Where to create the sim-data directory.

None

Returns:

Type Description
Any

Parsed result (typically dict[str, Path] of output files).

Raises:

Type Description
ValueError

If no job has been submitted yet.

Source code in src/gsim/palace/driven.py
def wait_for_results(
    self,
    *,
    verbose: bool = True,
    parent_dir: str | Path | None = None,
) -> Any:
    """Wait for this sim's cloud job, download and parse results.

    Args:
        verbose: Print progress messages.
        parent_dir: Where to create the sim-data directory.

    Returns:
        Parsed result (typically ``dict[str, Path]`` of output files).

    Raises:
        ValueError: If no job has been submitted yet.
    """
    from gsim import gcloud

    if self._job_id is None:
        raise ValueError("No job submitted yet")
    return gcloud.wait_for_results(
        self._job_id, verbose=verbose, parent_dir=parent_dir
    )

write_config

write_config() -> Path

Write Palace config.json after mesh generation.

Use this when mesh() was called with write_config=False.

Returns:

Type Description
Path

Path to the generated config.json

Raises:

Type Description
ValueError

If mesh() hasn't been called yet

Example

result = sim.mesh("./sim", write_config=False) config_path = sim.write_config()

Source code in src/gsim/palace/driven.py
def write_config(self) -> Path:
    """Write Palace config.json after mesh generation.

    Use this when mesh() was called with write_config=False.

    Returns:
        Path to the generated config.json

    Raises:
        ValueError: If mesh() hasn't been called yet

    Example:
        >>> result = sim.mesh("./sim", write_config=False)
        >>> config_path = sim.write_config()
    """
    from gsim.palace.mesh.generator import write_config as gen_write_config

    if self._last_mesh_result is None:
        raise ValueError("No mesh result. Call mesh() first.")

    if not self._last_mesh_result.groups:
        raise ValueError(
            "Mesh result has no groups data. "
            "Was mesh() called with write_config=True already?"
        )

    stack = self._resolve_stack()
    config_path = gen_write_config(
        mesh_result=self._last_mesh_result,
        stack=stack,
        ports=self._last_ports,
        driven_config=self.driven,
    )

    # Validate mesh and config
    validation = self.validate_mesh()
    if not validation.valid:
        raise ValueError(f"Mesh validation failed:\n{validation}")

    return config_path

EigenmodeConfig

Bases: BaseModel

Configuration for eigenmode (resonance) simulation.

This is used for finding resonant frequencies and mode shapes.

Attributes:

Name Type Description
num_modes int

Number of modes to find

target float | None

Target frequency in Hz for mode search

tolerance float

Eigenvalue solver tolerance

Methods:

Name Description
to_palace_config

Convert to Palace JSON config format.

to_palace_config

to_palace_config() -> dict

Convert to Palace JSON config format.

Source code in src/gsim/palace/models/problems.py
def to_palace_config(self) -> dict:
    """Convert to Palace JSON config format."""
    config: dict = {
        "N": self.num_modes,
        "Tol": self.tolerance,
    }
    if self.target is not None:
        config["Target"] = self.target / 1e9  # Convert to GHz
    return config

EigenmodeSim

Bases: PalaceSimMixin, BaseModel

Eigenmode simulation for finding resonant frequencies.

This class configures and runs eigenmode simulations to find resonant frequencies and mode shapes of structures.

Example

from gsim.palace import EigenmodeSim

sim = EigenmodeSim() sim.set_geometry(component) sim.set_stack(air_above=300.0) sim.add_port("o1", layer="topmetal2", length=5.0) sim.set_eigenmode(num_modes=10, target=50e9) sim.set_output_dir("./sim") sim.mesh(preset="default") results = sim.run()

Attributes:

Name Type Description
geometry Geometry | None

Wrapped gdsfactory Component (from common)

stack LayerStack | None

Layer stack configuration (from common)

ports list[PortConfig]

List of single-element port configurations

cpw_ports list[CPWPortConfig]

List of CPW (two-element) port configurations

eigenmode EigenmodeConfig

Eigenmode simulation configuration

materials dict[str, MaterialConfig]

Material property overrides

numerical NumericalConfig

Numerical solver configuration

Methods:

Name Description
add_cpw_port

Add a coplanar waveguide (CPW) port.

add_port

Add a single-element lumped port.

mesh

Generate the mesh for Palace simulation.

plot_mesh

Plot the mesh using PyVista.

plot_stack

Plot the layer stack visualization.

preview

Preview the mesh without running simulation.

run

Run eigenmode simulation on GDSFactory+ cloud.

set_eigenmode

Configure eigenmode simulation.

set_geometry

Set the gdsfactory component for simulation.

set_material

Override or add material properties.

set_numerical

Configure numerical solver parameters.

set_output_dir

Set the output directory for mesh and config files.

set_stack

Configure the layer stack.

show_stack

Print the layer stack table.

validate_config

Validate the simulation configuration.

validate_mesh

Validate the generated mesh and config before cloud submission.

component property

component: Component | None

Get the current component (for backward compatibility).

output_dir property

output_dir: Path | None

Get the current output directory.

add_cpw_port

add_cpw_port(
    name: str,
    *,
    layer: str,
    s_width: float,
    gap_width: float,
    length: float,
    offset: float = 0.0,
    impedance: float = 50.0,
    excited: bool = True,
) -> None

Add a coplanar waveguide (CPW) port.

Parameters:

Name Type Description Default
name str

Name of the port on the component (at signal center)

required
layer str

Target conductor layer

required
s_width float

Signal conductor width (um)

required
gap_width float

Gap width between signal and ground (um)

required
length float

Port extent along direction (um)

required
offset float

Shift port inward along the waveguide (um). Positive moves away from the boundary, into the conductor.

0.0
impedance float

Port impedance (Ohms)

50.0
excited bool

Whether this port is excited

True
Example

sim.add_cpw_port( ... "o1", layer="topmetal2", s_width=10, gap_width=6, length=5 ... )

Source code in src/gsim/palace/eigenmode.py
def add_cpw_port(
    self,
    name: str,
    *,
    layer: str,
    s_width: float,
    gap_width: float,
    length: float,
    offset: float = 0.0,
    impedance: float = 50.0,
    excited: bool = True,
) -> None:
    """Add a coplanar waveguide (CPW) port.

    Args:
        name: Name of the port on the component (at signal center)
        layer: Target conductor layer
        s_width: Signal conductor width (um)
        gap_width: Gap width between signal and ground (um)
        length: Port extent along direction (um)
        offset: Shift port inward along the waveguide (um).
            Positive moves away from the boundary, into the conductor.
        impedance: Port impedance (Ohms)
        excited: Whether this port is excited

    Example:
        >>> sim.add_cpw_port(
        ...     "o1", layer="topmetal2", s_width=10, gap_width=6, length=5
        ... )
    """
    self.cpw_ports = [p for p in self.cpw_ports if p.name != name]
    self.cpw_ports.append(
        CPWPortConfig(
            name=name,
            layer=layer,
            s_width=s_width,
            gap_width=gap_width,
            length=length,
            offset=offset,
            impedance=impedance,
            excited=excited,
        )
    )

add_port

add_port(
    name: str,
    *,
    layer: str | None = None,
    from_layer: str | None = None,
    to_layer: str | None = None,
    length: float | None = None,
    impedance: float = 50.0,
    resistance: float | None = None,
    inductance: float | None = None,
    capacitance: float | None = None,
    excited: bool = True,
    geometry: Literal["inplane", "via"] = "inplane",
) -> None

Add a single-element lumped port.

Parameters:

Name Type Description Default
name str

Port name (must match component port name)

required
layer str | None

Target layer for inplane ports

None
from_layer str | None

Bottom layer for via ports

None
to_layer str | None

Top layer for via ports

None
length float | None

Port extent along direction (um)

None
impedance float

Port impedance (Ohms)

50.0
resistance float | None

Series resistance (Ohms)

None
inductance float | None

Series inductance (H)

None
capacitance float | None

Shunt capacitance (F)

None
excited bool

Whether this port is excited

True
geometry Literal['inplane', 'via']

Port geometry type ("inplane" or "via")

'inplane'
Example

sim.add_port("o1", layer="topmetal2", length=5.0) sim.add_port( ... "junction", layer="SUPERCONDUCTOR", length=5.0, inductance=10e-9 ... )

Source code in src/gsim/palace/eigenmode.py
def add_port(
    self,
    name: str,
    *,
    layer: str | None = None,
    from_layer: str | None = None,
    to_layer: str | None = None,
    length: float | None = None,
    impedance: float = 50.0,
    resistance: float | None = None,
    inductance: float | None = None,
    capacitance: float | None = None,
    excited: bool = True,
    geometry: Literal["inplane", "via"] = "inplane",
) -> None:
    """Add a single-element lumped port.

    Args:
        name: Port name (must match component port name)
        layer: Target layer for inplane ports
        from_layer: Bottom layer for via ports
        to_layer: Top layer for via ports
        length: Port extent along direction (um)
        impedance: Port impedance (Ohms)
        resistance: Series resistance (Ohms)
        inductance: Series inductance (H)
        capacitance: Shunt capacitance (F)
        excited: Whether this port is excited
        geometry: Port geometry type ("inplane" or "via")

    Example:
        >>> sim.add_port("o1", layer="topmetal2", length=5.0)
        >>> sim.add_port(
        ...     "junction", layer="SUPERCONDUCTOR", length=5.0, inductance=10e-9
        ... )
    """
    self.ports = [p for p in self.ports if p.name != name]
    self.ports.append(
        PortConfig(
            name=name,
            layer=layer,
            from_layer=from_layer,
            to_layer=to_layer,
            length=length,
            impedance=impedance,
            resistance=resistance,
            inductance=inductance,
            capacitance=capacitance,
            excited=excited,
            geometry=geometry,
        )
    )

mesh

mesh(
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = False,
    model_name: str = "palace",
    verbose: bool = True,
) -> SimulationResult

Generate the mesh for Palace simulation.

Requires set_output_dir() to be called first.

Parameters:

Name Type Description Default
preset Literal['coarse', 'default', 'graded', 'fine'] | None

Mesh quality preset ("coarse", "default", "graded", "fine")

None
refined_mesh_size float | None

Mesh size near conductors (um), overrides preset

None
max_mesh_size float | None

Max mesh size in air/dielectric (um), overrides preset

None
margin float | None

XY margin around design (um), overrides preset

None
airbox_margin float | None

Extra airbox around stack (um); 0 = disabled

None
fmax float | None

Max frequency for mesh sizing (Hz), overrides preset

None
planar_conductors bool | None

Treat conductors as 2D PEC surfaces

None
show_gui bool

Show gmsh GUI during meshing

False
model_name str

Base name for output files

'palace'
verbose bool

Print progress messages

True

Returns:

Type Description
SimulationResult

SimulationResult with mesh path

Raises:

Type Description
ValueError

If output_dir not set or configuration is invalid

Example

sim.set_output_dir("./sim") result = sim.mesh(preset="fine", planar_conductors=True) print(f"Mesh saved to: {result.mesh_path}")

Source code in src/gsim/palace/eigenmode.py
def mesh(
    self,
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = False,
    model_name: str = "palace",
    verbose: bool = True,
) -> SimulationResult:
    """Generate the mesh for Palace simulation.

    Requires set_output_dir() to be called first.

    Args:
        preset: Mesh quality preset ("coarse", "default", "graded", "fine")
        refined_mesh_size: Mesh size near conductors (um), overrides preset
        max_mesh_size: Max mesh size in air/dielectric (um), overrides preset
        margin: XY margin around design (um), overrides preset
        airbox_margin: Extra airbox around stack (um); 0 = disabled
        fmax: Max frequency for mesh sizing (Hz), overrides preset
        planar_conductors: Treat conductors as 2D PEC surfaces
        show_gui: Show gmsh GUI during meshing
        model_name: Base name for output files
        verbose: Print progress messages

    Returns:
        SimulationResult with mesh path

    Raises:
        ValueError: If output_dir not set or configuration is invalid

    Example:
        >>> sim.set_output_dir("./sim")
        >>> result = sim.mesh(preset="fine", planar_conductors=True)
        >>> print(f"Mesh saved to: {result.mesh_path}")
    """
    from gsim.palace.ports import extract_ports

    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    component = self.geometry.component if self.geometry else None

    mesh_config = self._build_mesh_config(
        preset=preset,
        refined_mesh_size=refined_mesh_size,
        max_mesh_size=max_mesh_size,
        margin=margin,
        airbox_margin=airbox_margin,
        fmax=fmax,
        planar_conductors=planar_conductors,
        show_gui=show_gui,
    )

    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    output_dir = self._output_dir

    stack = self._resolve_stack()

    palace_ports = []
    if self.ports or self.cpw_ports:
        self._configure_ports_on_component(stack)
        palace_ports = extract_ports(component, stack)

    return self._generate_mesh_internal(
        output_dir=output_dir,
        mesh_config=mesh_config,
        ports=palace_ports,
        model_name=model_name,
        verbose=verbose,
    )

plot_mesh

plot_mesh(
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None

Plot the mesh using PyVista.

Requires mesh() to be called first.

Parameters:

Name Type Description Default
output str | Path | None

Output PNG path (only used if interactive=False)

None
show_groups list[str] | None

List of group name patterns to show (None = all). Example: ["metal", "P"] to show metal layers and ports.

None
interactive bool

If True, open interactive 3D viewer. If False, save static PNG to output path.

True
style Literal['wireframe', 'solid']

"wireframe" (edges only) or "solid" (coloured surfaces per physical group).

'wireframe'
transparent_groups list[str] | None

Group names rendered at low opacity in solid mode. Ignored in wireframe mode.

None

Raises:

Type Description
ValueError

If output_dir not set or mesh file doesn't exist

Example

sim.mesh(preset="default") sim.plot_mesh(show_groups=["metal", "P"]) sim.plot_mesh(style="solid", transparent_groups=["Absorbing_boundary"])

Source code in src/gsim/palace/base.py
def plot_mesh(
    self,
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None:
    """Plot the mesh using PyVista.

    Requires mesh() to be called first.

    Args:
        output: Output PNG path (only used if interactive=False)
        show_groups: List of group name patterns to show (None = all).
            Example: ["metal", "P"] to show metal layers and ports.
        interactive: If True, open interactive 3D viewer.
            If False, save static PNG to output path.
        style: ``"wireframe"`` (edges only) or ``"solid"`` (coloured
            surfaces per physical group).
        transparent_groups: Group names rendered at low opacity in
            *solid* mode.  Ignored in *wireframe* mode.

    Raises:
        ValueError: If output_dir not set or mesh file doesn't exist

    Example:
        >>> sim.mesh(preset="default")
        >>> sim.plot_mesh(show_groups=["metal", "P"])
        >>> sim.plot_mesh(style="solid", transparent_groups=["Absorbing_boundary"])
    """
    from gsim.viz import plot_mesh as _plot_mesh

    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    mesh_path = self._output_dir / "palace.msh"
    if not mesh_path.exists():
        raise ValueError(f"Mesh file not found: {mesh_path}. Call mesh() first.")

    # Default output path if not interactive
    if output is None and not interactive:
        output = self._output_dir / "mesh.png"

    _plot_mesh(
        msh_path=mesh_path,
        output=output,
        show_groups=show_groups,
        interactive=interactive,
        style=style,
        transparent_groups=transparent_groups,
    )

plot_stack

plot_stack() -> None

Plot the layer stack visualization.

Example

sim.plot_stack()

Source code in src/gsim/palace/base.py
def plot_stack(self) -> None:
    """Plot the layer stack visualization.

    Example:
        >>> sim.plot_stack()
    """
    from gsim.common.stack import plot_stack

    if self.stack is None:
        self._resolve_stack()

    if self.stack is not None:
        plot_stack(self.stack)

preview

preview(
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = True,
) -> None

Preview the mesh without running simulation.

Parameters:

Name Type Description Default
preset Literal['coarse', 'default', 'graded', 'fine'] | None

Mesh quality preset ("coarse", "default", "graded", "fine")

None
refined_mesh_size float | None

Mesh size near conductors (um)

None
max_mesh_size float | None

Max mesh size in air/dielectric (um)

None
margin float | None

XY margin around design (um)

None
airbox_margin float | None

Extra airbox around stack (um); 0 = disabled

None
fmax float | None

Max frequency for mesh sizing (Hz)

None
planar_conductors bool | None

Treat conductors as 2D PEC surfaces

None
show_gui bool

Show gmsh GUI for interactive preview

True
Example

sim.preview(preset="fine", planar_conductors=True, show_gui=True)

Source code in src/gsim/palace/eigenmode.py
def preview(
    self,
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = True,
) -> None:
    """Preview the mesh without running simulation.

    Args:
        preset: Mesh quality preset ("coarse", "default", "graded", "fine")
        refined_mesh_size: Mesh size near conductors (um)
        max_mesh_size: Max mesh size in air/dielectric (um)
        margin: XY margin around design (um)
        airbox_margin: Extra airbox around stack (um); 0 = disabled
        fmax: Max frequency for mesh sizing (Hz)
        planar_conductors: Treat conductors as 2D PEC surfaces
        show_gui: Show gmsh GUI for interactive preview

    Example:
        >>> sim.preview(preset="fine", planar_conductors=True, show_gui=True)
    """
    from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
    from gsim.palace.mesh import generate_mesh

    component = self.geometry.component if self.geometry else None

    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    mesh_config = self._build_mesh_config(
        preset=preset,
        refined_mesh_size=refined_mesh_size,
        max_mesh_size=max_mesh_size,
        margin=margin,
        airbox_margin=airbox_margin,
        fmax=fmax,
        planar_conductors=planar_conductors,
        show_gui=show_gui,
    )

    stack = self._resolve_stack()
    ports = self._get_ports_for_preview(stack)

    legacy_mesh_config = LegacyMeshConfig(
        refined_mesh_size=mesh_config.refined_mesh_size,
        max_mesh_size=mesh_config.max_mesh_size,
        cells_per_wavelength=mesh_config.cells_per_wavelength,
        margin=mesh_config.margin,
        airbox_margin=mesh_config.airbox_margin,
        fmax=mesh_config.fmax,
        show_gui=show_gui,
        preview_only=True,
        planar_conductors=mesh_config.planar_conductors,
        refine_from_curves=mesh_config.refine_from_curves,
    )

    with tempfile.TemporaryDirectory() as tmpdir:
        generate_mesh(
            component=component,
            stack=stack,
            ports=ports,
            output_dir=tmpdir,
            config=legacy_mesh_config,
        )

run

run(output_dir: str | Path | None = None, *, verbose: bool = True) -> dict[str, Path]

Run eigenmode simulation on GDSFactory+ cloud.

Parameters:

Name Type Description Default
output_dir str | Path | None

Directory containing mesh files

None
verbose bool

Print progress messages

True

Returns:

Type Description
dict[str, Path]

Dict mapping result filenames to local paths

Raises:

Type Description
NotImplementedError

Eigenmode is not yet fully implemented

Source code in src/gsim/palace/eigenmode.py
def run(
    self,
    output_dir: str | Path | None = None,
    *,
    verbose: bool = True,
) -> dict[str, Path]:
    """Run eigenmode simulation on GDSFactory+ cloud.

    Args:
        output_dir: Directory containing mesh files
        verbose: Print progress messages

    Returns:
        Dict mapping result filenames to local paths

    Raises:
        NotImplementedError: Eigenmode is not yet fully implemented
    """
    raise NotImplementedError(
        "Eigenmode simulation is not yet fully implemented on cloud. "
        "Use DrivenSim for S-parameter extraction."
    )

set_eigenmode

set_eigenmode(
    *, num_modes: int = 10, target: float | None = None, tolerance: float = 1e-06
) -> None

Configure eigenmode simulation.

Parameters:

Name Type Description Default
num_modes int

Number of modes to find

10
target float | None

Target frequency in Hz for mode search

None
tolerance float

Eigenvalue solver tolerance

1e-06
Example

sim.set_eigenmode(num_modes=10, target=50e9)

Source code in src/gsim/palace/eigenmode.py
def set_eigenmode(
    self,
    *,
    num_modes: int = 10,
    target: float | None = None,
    tolerance: float = 1e-6,
) -> None:
    """Configure eigenmode simulation.

    Args:
        num_modes: Number of modes to find
        target: Target frequency in Hz for mode search
        tolerance: Eigenvalue solver tolerance

    Example:
        >>> sim.set_eigenmode(num_modes=10, target=50e9)
    """
    self.eigenmode = EigenmodeConfig(
        num_modes=num_modes,
        target=target,
        tolerance=tolerance,
    )

set_geometry

set_geometry(component: Component) -> None

Set the gdsfactory component for simulation.

Parameters:

Name Type Description Default
component Component

gdsfactory Component to simulate

required
Example

sim.set_geometry(my_component)

Source code in src/gsim/palace/base.py
def set_geometry(self, component: Component) -> None:
    """Set the gdsfactory component for simulation.

    Args:
        component: gdsfactory Component to simulate

    Example:
        >>> sim.set_geometry(my_component)
    """
    from gsim.common import Geometry

    self.geometry = Geometry(component=component)

set_material

set_material(
    name: str,
    *,
    material_type: Literal["conductor", "dielectric", "semiconductor"] | None = None,
    conductivity: float | None = None,
    permittivity: float | None = None,
    loss_tangent: float | None = None,
) -> None

Override or add material properties.

Parameters:

Name Type Description Default
name str

Material name

required
material_type Literal['conductor', 'dielectric', 'semiconductor'] | None

Material type (conductor, dielectric, semiconductor)

None
conductivity float | None

Conductivity in S/m (for conductors)

None
permittivity float | None

Relative permittivity (for dielectrics)

None
loss_tangent float | None

Dielectric loss tangent

None
Example

sim.set_material( ... "aluminum", material_type="conductor", conductivity=3.8e7 ... ) sim.set_material("sio2", material_type="dielectric", permittivity=3.9)

Source code in src/gsim/palace/base.py
def set_material(
    self,
    name: str,
    *,
    material_type: Literal["conductor", "dielectric", "semiconductor"]
    | None = None,
    conductivity: float | None = None,
    permittivity: float | None = None,
    loss_tangent: float | None = None,
) -> None:
    """Override or add material properties.

    Args:
        name: Material name
        material_type: Material type (conductor, dielectric, semiconductor)
        conductivity: Conductivity in S/m (for conductors)
        permittivity: Relative permittivity (for dielectrics)
        loss_tangent: Dielectric loss tangent

    Example:
        >>> sim.set_material(
        ...     "aluminum", material_type="conductor", conductivity=3.8e7
        ... )
        >>> sim.set_material("sio2", material_type="dielectric", permittivity=3.9)
    """
    from gsim.palace.models import MaterialConfig

    # Determine type if not provided
    resolved_type = material_type
    if resolved_type is None:
        if conductivity is not None and conductivity > 1e4:
            resolved_type = "conductor"
        elif permittivity is not None:
            resolved_type = "dielectric"
        else:
            resolved_type = "dielectric"

    self.materials[name] = MaterialConfig(
        type=resolved_type,
        conductivity=conductivity,
        permittivity=permittivity,
        loss_tangent=loss_tangent,
    )

set_numerical

set_numerical(
    *,
    order: int = 1,
    tolerance: float = 1e-06,
    max_iterations: int = 400,
    solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
    preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
    device: Literal["CPU", "GPU"] = "CPU",
    num_processors: int | None = None,
) -> None

Configure numerical solver parameters.

Parameters:

Name Type Description Default
order int

Finite element order (1-4)

1
tolerance float

Linear solver tolerance

1e-06
max_iterations int

Maximum solver iterations

400
solver_type Literal['Default', 'SuperLU', 'STRUMPACK', 'MUMPS']

Linear solver type

'Default'
preconditioner Literal['Default', 'AMS', 'BoomerAMG']

Preconditioner type

'Default'
device Literal['CPU', 'GPU']

Compute device (CPU or GPU)

'CPU'
num_processors int | None

Number of processors (None = auto)

None
Example

sim.set_numerical(order=3, tolerance=1e-8)

Source code in src/gsim/palace/base.py
def set_numerical(
    self,
    *,
    order: int = 1,
    tolerance: float = 1e-6,
    max_iterations: int = 400,
    solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
    preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
    device: Literal["CPU", "GPU"] = "CPU",
    num_processors: int | None = None,
) -> None:
    """Configure numerical solver parameters.

    Args:
        order: Finite element order (1-4)
        tolerance: Linear solver tolerance
        max_iterations: Maximum solver iterations
        solver_type: Linear solver type
        preconditioner: Preconditioner type
        device: Compute device (CPU or GPU)
        num_processors: Number of processors (None = auto)

    Example:
        >>> sim.set_numerical(order=3, tolerance=1e-8)
    """
    from gsim.palace.models import NumericalConfig

    self.numerical = NumericalConfig(
        order=order,
        tolerance=tolerance,
        max_iterations=max_iterations,
        solver_type=solver_type,
        preconditioner=preconditioner,
        device=device,
        num_processors=num_processors,
    )

set_output_dir

set_output_dir(path: str | Path) -> None

Set the output directory for mesh and config files.

Parameters:

Name Type Description Default
path str | Path

Directory path for output files

required
Example

sim.set_output_dir("./palace-sim")

Source code in src/gsim/palace/base.py
def set_output_dir(self, path: str | Path) -> None:
    """Set the output directory for mesh and config files.

    Args:
        path: Directory path for output files

    Example:
        >>> sim.set_output_dir("./palace-sim")
    """
    self._output_dir = Path(path)
    self._output_dir.mkdir(parents=True, exist_ok=True)

set_stack

set_stack(
    stack: LayerStack | None = None,
    *,
    yaml_path: str | Path | None = None,
    air_above: float = 200.0,
    substrate_thickness: float = 2.0,
    include_substrate: bool = False,
    **kwargs,
) -> None

Configure the layer stack.

Three modes of use:

  1. Active PDK (default — auto-detects IHP, QPDK, etc.)::

    sim.set_stack(air_above=300.0, substrate_thickness=2.0)

  2. YAML file::

    sim.set_stack(yaml_path="custom_stack.yaml")

  3. Custom stack (advanced — pass a hand-built LayerStack)::

    sim.set_stack(my_layer_stack)

Parameters:

Name Type Description Default
stack LayerStack | None

Custom gsim LayerStack (bypasses PDK extraction).

None
yaml_path str | Path | None

Path to custom YAML stack file.

None
air_above float

Air box height above top metal in um.

200.0
substrate_thickness float

Thickness below z=0 in um.

2.0
include_substrate bool

Include lossy silicon substrate.

False
**kwargs

Additional args passed to extract_layer_stack.

{}
Example

sim.set_stack(air_above=300.0, substrate_thickness=2.0)

Source code in src/gsim/palace/base.py
def set_stack(
    self,
    stack: LayerStack | None = None,
    *,
    yaml_path: str | Path | None = None,
    air_above: float = 200.0,
    substrate_thickness: float = 2.0,
    include_substrate: bool = False,
    **kwargs,
) -> None:
    """Configure the layer stack.

    Three modes of use:

    1. **Active PDK** (default — auto-detects IHP, QPDK, etc.)::

           sim.set_stack(air_above=300.0, substrate_thickness=2.0)

    2. **YAML file**::

           sim.set_stack(yaml_path="custom_stack.yaml")

    3. **Custom stack** (advanced — pass a hand-built LayerStack)::

           sim.set_stack(my_layer_stack)

    Args:
        stack: Custom gsim LayerStack (bypasses PDK extraction).
        yaml_path: Path to custom YAML stack file.
        air_above: Air box height above top metal in um.
        substrate_thickness: Thickness below z=0 in um.
        include_substrate: Include lossy silicon substrate.
        **kwargs: Additional args passed to extract_layer_stack.

    Example:
        >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0)
    """
    if stack is not None:
        # Directly use a pre-built LayerStack — skip lazy resolution
        self.stack = stack
        self._stack_kwargs = {"_prebuilt": True}
        return

    self._stack_kwargs = {
        "yaml_path": yaml_path,
        "air_above": air_above,
        "substrate_thickness": substrate_thickness,
        "include_substrate": include_substrate,
        **kwargs,
    }
    # Stack will be resolved lazily during mesh() or simulate()
    self.stack = None

show_stack

show_stack() -> None

Print the layer stack table.

Example

sim.show_stack()

Source code in src/gsim/palace/base.py
def show_stack(self) -> None:
    """Print the layer stack table.

    Example:
        >>> sim.show_stack()
    """
    from gsim.common.stack import print_stack_table

    if self.stack is None:
        self._resolve_stack()

    if self.stack is not None:
        print_stack_table(self.stack)

validate_config

validate_config() -> ValidationResult

Validate the simulation configuration.

Returns:

Type Description
ValidationResult

ValidationResult with validation status and messages

Source code in src/gsim/palace/eigenmode.py
def validate_config(self) -> ValidationResult:
    """Validate the simulation configuration.

    Returns:
        ValidationResult with validation status and messages
    """
    errors = []
    warnings_list = []

    # Check geometry
    if self.geometry is None:
        errors.append("No component set. Call set_geometry(component) first.")

    # Check stack
    if self.stack is None and not self._stack_kwargs:
        warnings_list.append(
            "No stack configured. Will use active PDK with defaults."
        )

    # Eigenmode simulations may not require ports
    if not self.ports and not self.cpw_ports:
        warnings_list.append(
            "No ports configured. Eigenmode finds all modes without port loading."
        )

    # Validate port configurations
    for port in self.ports:
        if port.geometry == "inplane" and port.layer is None:
            errors.append(f"Port '{port.name}': inplane ports require 'layer'")
        if port.geometry == "via" and (
            port.from_layer is None or port.to_layer is None
        ):
            errors.append(
                f"Port '{port.name}': via ports require 'from_layer' and 'to_layer'"
            )

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

validate_mesh

validate_mesh() -> ValidationResult

Validate the generated mesh and config before cloud submission.

Checks that physical groups are correctly assigned after meshing: conductor surfaces, dielectric volumes, ports, and absorbing boundary. Also verifies the generated config.json structure.

Call after mesh() and before run().

Returns:

Type Description
ValidationResult

ValidationResult with validation status and messages

Example

sim.mesh(preset="coarse") result = sim.validate_mesh() print(result)

Source code in src/gsim/palace/base.py
def validate_mesh(self) -> ValidationResult:
    """Validate the generated mesh and config before cloud submission.

    Checks that physical groups are correctly assigned after meshing:
    conductor surfaces, dielectric volumes, ports, and absorbing boundary.
    Also verifies the generated config.json structure.

    Call after mesh() and before run().

    Returns:
        ValidationResult with validation status and messages

    Example:
        >>> sim.mesh(preset="coarse")
        >>> result = sim.validate_mesh()
        >>> print(result)
    """
    errors = []
    warnings_list = []

    mesh_result = getattr(self, "_mesh_result", None) or getattr(
        self, "_last_mesh_result", None
    )
    if mesh_result is None:
        errors.append("No mesh generated. Call mesh() first.")
        return ValidationResult(valid=False, errors=errors, warnings=warnings_list)

    groups = mesh_result.groups

    # Check dielectric volumes
    if not groups.get("volumes"):
        errors.append("No dielectric volumes in mesh.")
    else:
        vol_names = list(groups["volumes"].keys())
        warnings_list.append(f"Volumes: {vol_names}")

    # Check conductor surfaces (volumetric or PEC)
    has_conductors = bool(groups.get("conductor_surfaces"))
    has_pec = bool(groups.get("pec_surfaces"))
    if not has_conductors and not has_pec:
        errors.append(
            "No conductor surfaces in mesh. "
            "Check that conductor layers have polygons and correct layer_type."
        )
    else:
        if has_conductors:
            warnings_list.append(
                f"Conductor surfaces: {list(groups['conductor_surfaces'].keys())}"
            )
        if has_pec:
            warnings_list.append(
                f"PEC surfaces: {list(groups['pec_surfaces'].keys())}"
            )

    # Check ports
    port_surfaces = groups.get("port_surfaces", {})
    if not port_surfaces:
        errors.append("No port surfaces in mesh.")
    else:
        for port_name, port_info in port_surfaces.items():
            if port_info.get("type") == "cpw":
                n_elems = len(port_info.get("elements", []))
                if n_elems < 2:
                    errors.append(
                        f"CPW port '{port_name}' has {n_elems} elements "
                        f"(expected >= 2)."
                    )

    # Check absorbing boundary
    if not groups.get("boundary_surfaces", {}).get("absorbing"):
        warnings_list.append(
            "No absorbing boundary found. This is expected if airbox_margin=0."
        )

    # Validate config.json if it exists
    output_dir = getattr(self, "_output_dir", None)
    if output_dir is not None:
        import json

        config_path = output_dir / "config.json"
        if config_path.exists():
            try:
                config = json.loads(config_path.read_text())
                boundaries = config.get("Boundaries", {})
                if not boundaries.get("Conductivity") and not boundaries.get("PEC"):
                    errors.append(
                        "config.json has no Conductivity or PEC boundaries."
                    )
                if not boundaries.get("LumpedPort"):
                    errors.append("config.json has no LumpedPort entries.")
            except json.JSONDecodeError as e:
                errors.append(f"config.json is invalid JSON: {e}")

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

ElectrostaticConfig

Bases: BaseModel

Configuration for electrostatic (capacitance matrix) simulation.

Attributes:

Name Type Description
save_fields int

Number of field solutions to save

Methods:

Name Description
to_palace_config

Convert to Palace JSON config format.

to_palace_config

to_palace_config() -> dict

Convert to Palace JSON config format.

Source code in src/gsim/palace/models/problems.py
def to_palace_config(self) -> dict:
    """Convert to Palace JSON config format."""
    return {
        "Save": self.save_fields,
    }

ElectrostaticSim

Bases: PalaceSimMixin, BaseModel

Electrostatic simulation for capacitance matrix extraction.

This class configures and runs electrostatic simulations to extract the capacitance matrix between conductor terminals. Unlike driven and eigenmode simulations, this does not use ports.

Example

from gsim.palace import ElectrostaticSim

sim = ElectrostaticSim() sim.set_geometry(component) sim.set_stack(air_above=300.0) sim.add_terminal("T1", layer="topmetal2") sim.add_terminal("T2", layer="topmetal2") sim.set_electrostatic() sim.set_output_dir("./sim") sim.mesh(preset="default") results = sim.run()

Attributes:

Name Type Description
geometry Geometry | None

Wrapped gdsfactory Component (from common)

stack LayerStack | None

Layer stack configuration (from common)

terminals list[TerminalConfig]

List of terminal configurations

electrostatic ElectrostaticConfig

Electrostatic simulation configuration

materials dict[str, MaterialConfig]

Material property overrides

numerical NumericalConfig

Numerical solver configuration

Methods:

Name Description
add_terminal

Add a terminal for capacitance extraction.

mesh

Generate the mesh for Palace simulation.

plot_mesh

Plot the mesh using PyVista.

plot_stack

Plot the layer stack visualization.

preview

Preview the mesh without running simulation.

run

Run electrostatic simulation on GDSFactory+ cloud.

set_electrostatic

Configure electrostatic simulation.

set_geometry

Set the gdsfactory component for simulation.

set_material

Override or add material properties.

set_numerical

Configure numerical solver parameters.

set_output_dir

Set the output directory for mesh and config files.

set_stack

Configure the layer stack.

show_stack

Print the layer stack table.

validate_config

Validate the simulation configuration.

validate_mesh

Validate the generated mesh and config before cloud submission.

component property

component: Component | None

Get the current component (for backward compatibility).

output_dir property

output_dir: Path | None

Get the current output directory.

add_terminal

add_terminal(name: str, *, layer: str) -> None

Add a terminal for capacitance extraction.

Terminals define conductor surfaces for capacitance matrix extraction.

Parameters:

Name Type Description Default
name str

Terminal name

required
layer str

Target conductor layer

required
Example

sim.add_terminal("T1", layer="topmetal2") sim.add_terminal("T2", layer="topmetal2")

Source code in src/gsim/palace/electrostatic.py
def add_terminal(
    self,
    name: str,
    *,
    layer: str,
) -> None:
    """Add a terminal for capacitance extraction.

    Terminals define conductor surfaces for capacitance matrix extraction.

    Args:
        name: Terminal name
        layer: Target conductor layer

    Example:
        >>> sim.add_terminal("T1", layer="topmetal2")
        >>> sim.add_terminal("T2", layer="topmetal2")
    """
    # Remove existing terminal with same name
    self.terminals = [t for t in self.terminals if t.name != name]
    self.terminals.append(
        TerminalConfig(
            name=name,
            layer=layer,
        )
    )

mesh

mesh(
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = False,
    model_name: str = "palace",
    verbose: bool = True,
) -> SimulationResult

Generate the mesh for Palace simulation.

Requires set_output_dir() to be called first.

Parameters:

Name Type Description Default
preset Literal['coarse', 'default', 'graded', 'fine'] | None

Mesh quality preset ("coarse", "default", "graded", "fine")

None
refined_mesh_size float | None

Mesh size near conductors (um), overrides preset

None
max_mesh_size float | None

Max mesh size in air/dielectric (um), overrides preset

None
margin float | None

XY margin around design (um), overrides preset

None
airbox_margin float | None

Extra airbox around stack (um); 0 = disabled

None
fmax float | None

Max frequency for mesh sizing (Hz) - less relevant for electrostatic

None
planar_conductors bool | None

Treat conductors as 2D PEC surfaces

None
show_gui bool

Show gmsh GUI during meshing

False
model_name str

Base name for output files

'palace'
verbose bool

Print progress messages

True

Returns:

Type Description
SimulationResult

SimulationResult with mesh path

Raises:

Type Description
ValueError

If output_dir not set or configuration is invalid

Example

sim.set_output_dir("./sim") result = sim.mesh(preset="fine", planar_conductors=True) print(f"Mesh saved to: {result.mesh_path}")

Source code in src/gsim/palace/electrostatic.py
def mesh(
    self,
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = False,
    model_name: str = "palace",
    verbose: bool = True,
) -> SimulationResult:
    """Generate the mesh for Palace simulation.

    Requires set_output_dir() to be called first.

    Args:
        preset: Mesh quality preset ("coarse", "default", "graded", "fine")
        refined_mesh_size: Mesh size near conductors (um), overrides preset
        max_mesh_size: Max mesh size in air/dielectric (um), overrides preset
        margin: XY margin around design (um), overrides preset
        airbox_margin: Extra airbox around stack (um); 0 = disabled
        fmax: Max frequency for mesh sizing (Hz) - less relevant for electrostatic
        planar_conductors: Treat conductors as 2D PEC surfaces
        show_gui: Show gmsh GUI during meshing
        model_name: Base name for output files
        verbose: Print progress messages

    Returns:
        SimulationResult with mesh path

    Raises:
        ValueError: If output_dir not set or configuration is invalid

    Example:
        >>> sim.set_output_dir("./sim")
        >>> result = sim.mesh(preset="fine", planar_conductors=True)
        >>> print(f"Mesh saved to: {result.mesh_path}")
    """
    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    mesh_config = self._build_mesh_config(
        preset=preset,
        refined_mesh_size=refined_mesh_size,
        max_mesh_size=max_mesh_size,
        margin=margin,
        airbox_margin=airbox_margin,
        fmax=fmax,
        planar_conductors=planar_conductors,
        show_gui=show_gui,
    )

    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    output_dir = self._output_dir

    self._resolve_stack()

    return self._generate_mesh_internal(
        output_dir=output_dir,
        mesh_config=mesh_config,
        model_name=model_name,
        verbose=verbose,
    )

plot_mesh

plot_mesh(
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None

Plot the mesh using PyVista.

Requires mesh() to be called first.

Parameters:

Name Type Description Default
output str | Path | None

Output PNG path (only used if interactive=False)

None
show_groups list[str] | None

List of group name patterns to show (None = all). Example: ["metal", "P"] to show metal layers and ports.

None
interactive bool

If True, open interactive 3D viewer. If False, save static PNG to output path.

True
style Literal['wireframe', 'solid']

"wireframe" (edges only) or "solid" (coloured surfaces per physical group).

'wireframe'
transparent_groups list[str] | None

Group names rendered at low opacity in solid mode. Ignored in wireframe mode.

None

Raises:

Type Description
ValueError

If output_dir not set or mesh file doesn't exist

Example

sim.mesh(preset="default") sim.plot_mesh(show_groups=["metal", "P"]) sim.plot_mesh(style="solid", transparent_groups=["Absorbing_boundary"])

Source code in src/gsim/palace/base.py
def plot_mesh(
    self,
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None:
    """Plot the mesh using PyVista.

    Requires mesh() to be called first.

    Args:
        output: Output PNG path (only used if interactive=False)
        show_groups: List of group name patterns to show (None = all).
            Example: ["metal", "P"] to show metal layers and ports.
        interactive: If True, open interactive 3D viewer.
            If False, save static PNG to output path.
        style: ``"wireframe"`` (edges only) or ``"solid"`` (coloured
            surfaces per physical group).
        transparent_groups: Group names rendered at low opacity in
            *solid* mode.  Ignored in *wireframe* mode.

    Raises:
        ValueError: If output_dir not set or mesh file doesn't exist

    Example:
        >>> sim.mesh(preset="default")
        >>> sim.plot_mesh(show_groups=["metal", "P"])
        >>> sim.plot_mesh(style="solid", transparent_groups=["Absorbing_boundary"])
    """
    from gsim.viz import plot_mesh as _plot_mesh

    if self._output_dir is None:
        raise ValueError("Output directory not set. Call set_output_dir() first.")

    mesh_path = self._output_dir / "palace.msh"
    if not mesh_path.exists():
        raise ValueError(f"Mesh file not found: {mesh_path}. Call mesh() first.")

    # Default output path if not interactive
    if output is None and not interactive:
        output = self._output_dir / "mesh.png"

    _plot_mesh(
        msh_path=mesh_path,
        output=output,
        show_groups=show_groups,
        interactive=interactive,
        style=style,
        transparent_groups=transparent_groups,
    )

plot_stack

plot_stack() -> None

Plot the layer stack visualization.

Example

sim.plot_stack()

Source code in src/gsim/palace/base.py
def plot_stack(self) -> None:
    """Plot the layer stack visualization.

    Example:
        >>> sim.plot_stack()
    """
    from gsim.common.stack import plot_stack

    if self.stack is None:
        self._resolve_stack()

    if self.stack is not None:
        plot_stack(self.stack)

preview

preview(
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = True,
) -> None

Preview the mesh without running simulation.

Parameters:

Name Type Description Default
preset Literal['coarse', 'default', 'graded', 'fine'] | None

Mesh quality preset ("coarse", "default", "graded", "fine")

None
refined_mesh_size float | None

Mesh size near conductors (um)

None
max_mesh_size float | None

Max mesh size in air/dielectric (um)

None
margin float | None

XY margin around design (um)

None
airbox_margin float | None

Extra airbox around stack (um); 0 = disabled

None
fmax float | None

Max frequency for mesh sizing (Hz)

None
planar_conductors bool | None

Treat conductors as 2D PEC surfaces

None
show_gui bool

Show gmsh GUI for interactive preview

True
Example

sim.preview(preset="fine", planar_conductors=True, show_gui=True)

Source code in src/gsim/palace/electrostatic.py
def preview(
    self,
    *,
    preset: Literal["coarse", "default", "graded", "fine"] | None = None,
    refined_mesh_size: float | None = None,
    max_mesh_size: float | None = None,
    margin: float | None = None,
    airbox_margin: float | None = None,
    fmax: float | None = None,
    planar_conductors: bool | None = None,
    show_gui: bool = True,
) -> None:
    """Preview the mesh without running simulation.

    Args:
        preset: Mesh quality preset ("coarse", "default", "graded", "fine")
        refined_mesh_size: Mesh size near conductors (um)
        max_mesh_size: Max mesh size in air/dielectric (um)
        margin: XY margin around design (um)
        airbox_margin: Extra airbox around stack (um); 0 = disabled
        fmax: Max frequency for mesh sizing (Hz)
        planar_conductors: Treat conductors as 2D PEC surfaces
        show_gui: Show gmsh GUI for interactive preview

    Example:
        >>> sim.preview(preset="fine", planar_conductors=True, show_gui=True)
    """
    from gsim.palace.mesh import MeshConfig as LegacyMeshConfig
    from gsim.palace.mesh import generate_mesh

    component = self.geometry.component if self.geometry else None

    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    mesh_config = self._build_mesh_config(
        preset=preset,
        refined_mesh_size=refined_mesh_size,
        max_mesh_size=max_mesh_size,
        margin=margin,
        airbox_margin=airbox_margin,
        fmax=fmax,
        planar_conductors=planar_conductors,
        show_gui=show_gui,
    )

    stack = self._resolve_stack()

    legacy_mesh_config = LegacyMeshConfig(
        refined_mesh_size=mesh_config.refined_mesh_size,
        max_mesh_size=mesh_config.max_mesh_size,
        cells_per_wavelength=mesh_config.cells_per_wavelength,
        margin=mesh_config.margin,
        airbox_margin=mesh_config.airbox_margin,
        fmax=mesh_config.fmax,
        show_gui=show_gui,
        preview_only=True,
        planar_conductors=mesh_config.planar_conductors,
        refine_from_curves=mesh_config.refine_from_curves,
    )

    with tempfile.TemporaryDirectory() as tmpdir:
        generate_mesh(
            component=component,
            stack=stack,
            ports=[],
            output_dir=tmpdir,
            config=legacy_mesh_config,
        )

run

run(output_dir: str | Path | None = None, *, verbose: bool = True) -> dict[str, Path]

Run electrostatic simulation on GDSFactory+ cloud.

Parameters:

Name Type Description Default
output_dir str | Path | None

Directory containing mesh files

None
verbose bool

Print progress messages

True

Returns:

Type Description
dict[str, Path]

Dict mapping result filenames to local paths

Raises:

Type Description
NotImplementedError

Electrostatic is not yet fully implemented

Source code in src/gsim/palace/electrostatic.py
def run(
    self,
    output_dir: str | Path | None = None,
    *,
    verbose: bool = True,
) -> dict[str, Path]:
    """Run electrostatic simulation on GDSFactory+ cloud.

    Args:
        output_dir: Directory containing mesh files
        verbose: Print progress messages

    Returns:
        Dict mapping result filenames to local paths

    Raises:
        NotImplementedError: Electrostatic is not yet fully implemented
    """
    raise NotImplementedError(
        "Electrostatic simulation is not yet fully implemented on cloud. "
        "Use DrivenSim for S-parameter extraction."
    )

set_electrostatic

set_electrostatic(*, save_fields: int = 0) -> None

Configure electrostatic simulation.

Parameters:

Name Type Description Default
save_fields int

Number of field solutions to save

0
Example

sim.set_electrostatic(save_fields=1)

Source code in src/gsim/palace/electrostatic.py
def set_electrostatic(
    self,
    *,
    save_fields: int = 0,
) -> None:
    """Configure electrostatic simulation.

    Args:
        save_fields: Number of field solutions to save

    Example:
        >>> sim.set_electrostatic(save_fields=1)
    """
    self.electrostatic = ElectrostaticConfig(
        save_fields=save_fields,
    )

set_geometry

set_geometry(component: Component) -> None

Set the gdsfactory component for simulation.

Parameters:

Name Type Description Default
component Component

gdsfactory Component to simulate

required
Example

sim.set_geometry(my_component)

Source code in src/gsim/palace/base.py
def set_geometry(self, component: Component) -> None:
    """Set the gdsfactory component for simulation.

    Args:
        component: gdsfactory Component to simulate

    Example:
        >>> sim.set_geometry(my_component)
    """
    from gsim.common import Geometry

    self.geometry = Geometry(component=component)

set_material

set_material(
    name: str,
    *,
    material_type: Literal["conductor", "dielectric", "semiconductor"] | None = None,
    conductivity: float | None = None,
    permittivity: float | None = None,
    loss_tangent: float | None = None,
) -> None

Override or add material properties.

Parameters:

Name Type Description Default
name str

Material name

required
material_type Literal['conductor', 'dielectric', 'semiconductor'] | None

Material type (conductor, dielectric, semiconductor)

None
conductivity float | None

Conductivity in S/m (for conductors)

None
permittivity float | None

Relative permittivity (for dielectrics)

None
loss_tangent float | None

Dielectric loss tangent

None
Example

sim.set_material( ... "aluminum", material_type="conductor", conductivity=3.8e7 ... ) sim.set_material("sio2", material_type="dielectric", permittivity=3.9)

Source code in src/gsim/palace/base.py
def set_material(
    self,
    name: str,
    *,
    material_type: Literal["conductor", "dielectric", "semiconductor"]
    | None = None,
    conductivity: float | None = None,
    permittivity: float | None = None,
    loss_tangent: float | None = None,
) -> None:
    """Override or add material properties.

    Args:
        name: Material name
        material_type: Material type (conductor, dielectric, semiconductor)
        conductivity: Conductivity in S/m (for conductors)
        permittivity: Relative permittivity (for dielectrics)
        loss_tangent: Dielectric loss tangent

    Example:
        >>> sim.set_material(
        ...     "aluminum", material_type="conductor", conductivity=3.8e7
        ... )
        >>> sim.set_material("sio2", material_type="dielectric", permittivity=3.9)
    """
    from gsim.palace.models import MaterialConfig

    # Determine type if not provided
    resolved_type = material_type
    if resolved_type is None:
        if conductivity is not None and conductivity > 1e4:
            resolved_type = "conductor"
        elif permittivity is not None:
            resolved_type = "dielectric"
        else:
            resolved_type = "dielectric"

    self.materials[name] = MaterialConfig(
        type=resolved_type,
        conductivity=conductivity,
        permittivity=permittivity,
        loss_tangent=loss_tangent,
    )

set_numerical

set_numerical(
    *,
    order: int = 1,
    tolerance: float = 1e-06,
    max_iterations: int = 400,
    solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
    preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
    device: Literal["CPU", "GPU"] = "CPU",
    num_processors: int | None = None,
) -> None

Configure numerical solver parameters.

Parameters:

Name Type Description Default
order int

Finite element order (1-4)

1
tolerance float

Linear solver tolerance

1e-06
max_iterations int

Maximum solver iterations

400
solver_type Literal['Default', 'SuperLU', 'STRUMPACK', 'MUMPS']

Linear solver type

'Default'
preconditioner Literal['Default', 'AMS', 'BoomerAMG']

Preconditioner type

'Default'
device Literal['CPU', 'GPU']

Compute device (CPU or GPU)

'CPU'
num_processors int | None

Number of processors (None = auto)

None
Example

sim.set_numerical(order=3, tolerance=1e-8)

Source code in src/gsim/palace/base.py
def set_numerical(
    self,
    *,
    order: int = 1,
    tolerance: float = 1e-6,
    max_iterations: int = 400,
    solver_type: Literal["Default", "SuperLU", "STRUMPACK", "MUMPS"] = "Default",
    preconditioner: Literal["Default", "AMS", "BoomerAMG"] = "Default",
    device: Literal["CPU", "GPU"] = "CPU",
    num_processors: int | None = None,
) -> None:
    """Configure numerical solver parameters.

    Args:
        order: Finite element order (1-4)
        tolerance: Linear solver tolerance
        max_iterations: Maximum solver iterations
        solver_type: Linear solver type
        preconditioner: Preconditioner type
        device: Compute device (CPU or GPU)
        num_processors: Number of processors (None = auto)

    Example:
        >>> sim.set_numerical(order=3, tolerance=1e-8)
    """
    from gsim.palace.models import NumericalConfig

    self.numerical = NumericalConfig(
        order=order,
        tolerance=tolerance,
        max_iterations=max_iterations,
        solver_type=solver_type,
        preconditioner=preconditioner,
        device=device,
        num_processors=num_processors,
    )

set_output_dir

set_output_dir(path: str | Path) -> None

Set the output directory for mesh and config files.

Parameters:

Name Type Description Default
path str | Path

Directory path for output files

required
Example

sim.set_output_dir("./palace-sim")

Source code in src/gsim/palace/base.py
def set_output_dir(self, path: str | Path) -> None:
    """Set the output directory for mesh and config files.

    Args:
        path: Directory path for output files

    Example:
        >>> sim.set_output_dir("./palace-sim")
    """
    self._output_dir = Path(path)
    self._output_dir.mkdir(parents=True, exist_ok=True)

set_stack

set_stack(
    stack: LayerStack | None = None,
    *,
    yaml_path: str | Path | None = None,
    air_above: float = 200.0,
    substrate_thickness: float = 2.0,
    include_substrate: bool = False,
    **kwargs,
) -> None

Configure the layer stack.

Three modes of use:

  1. Active PDK (default — auto-detects IHP, QPDK, etc.)::

    sim.set_stack(air_above=300.0, substrate_thickness=2.0)

  2. YAML file::

    sim.set_stack(yaml_path="custom_stack.yaml")

  3. Custom stack (advanced — pass a hand-built LayerStack)::

    sim.set_stack(my_layer_stack)

Parameters:

Name Type Description Default
stack LayerStack | None

Custom gsim LayerStack (bypasses PDK extraction).

None
yaml_path str | Path | None

Path to custom YAML stack file.

None
air_above float

Air box height above top metal in um.

200.0
substrate_thickness float

Thickness below z=0 in um.

2.0
include_substrate bool

Include lossy silicon substrate.

False
**kwargs

Additional args passed to extract_layer_stack.

{}
Example

sim.set_stack(air_above=300.0, substrate_thickness=2.0)

Source code in src/gsim/palace/base.py
def set_stack(
    self,
    stack: LayerStack | None = None,
    *,
    yaml_path: str | Path | None = None,
    air_above: float = 200.0,
    substrate_thickness: float = 2.0,
    include_substrate: bool = False,
    **kwargs,
) -> None:
    """Configure the layer stack.

    Three modes of use:

    1. **Active PDK** (default — auto-detects IHP, QPDK, etc.)::

           sim.set_stack(air_above=300.0, substrate_thickness=2.0)

    2. **YAML file**::

           sim.set_stack(yaml_path="custom_stack.yaml")

    3. **Custom stack** (advanced — pass a hand-built LayerStack)::

           sim.set_stack(my_layer_stack)

    Args:
        stack: Custom gsim LayerStack (bypasses PDK extraction).
        yaml_path: Path to custom YAML stack file.
        air_above: Air box height above top metal in um.
        substrate_thickness: Thickness below z=0 in um.
        include_substrate: Include lossy silicon substrate.
        **kwargs: Additional args passed to extract_layer_stack.

    Example:
        >>> sim.set_stack(air_above=300.0, substrate_thickness=2.0)
    """
    if stack is not None:
        # Directly use a pre-built LayerStack — skip lazy resolution
        self.stack = stack
        self._stack_kwargs = {"_prebuilt": True}
        return

    self._stack_kwargs = {
        "yaml_path": yaml_path,
        "air_above": air_above,
        "substrate_thickness": substrate_thickness,
        "include_substrate": include_substrate,
        **kwargs,
    }
    # Stack will be resolved lazily during mesh() or simulate()
    self.stack = None

show_stack

show_stack() -> None

Print the layer stack table.

Example

sim.show_stack()

Source code in src/gsim/palace/base.py
def show_stack(self) -> None:
    """Print the layer stack table.

    Example:
        >>> sim.show_stack()
    """
    from gsim.common.stack import print_stack_table

    if self.stack is None:
        self._resolve_stack()

    if self.stack is not None:
        print_stack_table(self.stack)

validate_config

validate_config() -> ValidationResult

Validate the simulation configuration.

Returns:

Type Description
ValidationResult

ValidationResult with validation status and messages

Source code in src/gsim/palace/electrostatic.py
def validate_config(self) -> ValidationResult:
    """Validate the simulation configuration.

    Returns:
        ValidationResult with validation status and messages
    """
    errors = []
    warnings_list = []

    # Check geometry
    if self.geometry is None:
        errors.append("No component set. Call set_geometry(component) first.")

    # Check stack
    if self.stack is None and not self._stack_kwargs:
        warnings_list.append(
            "No stack configured. Will use active PDK with defaults."
        )

    # Electrostatic requires at least 2 terminals
    if len(self.terminals) < 2:
        errors.append(
            "Electrostatic simulation requires at least 2 terminals. "
            "Call add_terminal() to add terminals."
        )

    # Validate terminal configurations
    errors.extend(
        f"Terminal '{terminal.name}': 'layer' is required"
        for terminal in self.terminals
        if not terminal.layer
    )

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

validate_mesh

validate_mesh() -> ValidationResult

Validate the generated mesh and config before cloud submission.

Checks that physical groups are correctly assigned after meshing: conductor surfaces, dielectric volumes, ports, and absorbing boundary. Also verifies the generated config.json structure.

Call after mesh() and before run().

Returns:

Type Description
ValidationResult

ValidationResult with validation status and messages

Example

sim.mesh(preset="coarse") result = sim.validate_mesh() print(result)

Source code in src/gsim/palace/base.py
def validate_mesh(self) -> ValidationResult:
    """Validate the generated mesh and config before cloud submission.

    Checks that physical groups are correctly assigned after meshing:
    conductor surfaces, dielectric volumes, ports, and absorbing boundary.
    Also verifies the generated config.json structure.

    Call after mesh() and before run().

    Returns:
        ValidationResult with validation status and messages

    Example:
        >>> sim.mesh(preset="coarse")
        >>> result = sim.validate_mesh()
        >>> print(result)
    """
    errors = []
    warnings_list = []

    mesh_result = getattr(self, "_mesh_result", None) or getattr(
        self, "_last_mesh_result", None
    )
    if mesh_result is None:
        errors.append("No mesh generated. Call mesh() first.")
        return ValidationResult(valid=False, errors=errors, warnings=warnings_list)

    groups = mesh_result.groups

    # Check dielectric volumes
    if not groups.get("volumes"):
        errors.append("No dielectric volumes in mesh.")
    else:
        vol_names = list(groups["volumes"].keys())
        warnings_list.append(f"Volumes: {vol_names}")

    # Check conductor surfaces (volumetric or PEC)
    has_conductors = bool(groups.get("conductor_surfaces"))
    has_pec = bool(groups.get("pec_surfaces"))
    if not has_conductors and not has_pec:
        errors.append(
            "No conductor surfaces in mesh. "
            "Check that conductor layers have polygons and correct layer_type."
        )
    else:
        if has_conductors:
            warnings_list.append(
                f"Conductor surfaces: {list(groups['conductor_surfaces'].keys())}"
            )
        if has_pec:
            warnings_list.append(
                f"PEC surfaces: {list(groups['pec_surfaces'].keys())}"
            )

    # Check ports
    port_surfaces = groups.get("port_surfaces", {})
    if not port_surfaces:
        errors.append("No port surfaces in mesh.")
    else:
        for port_name, port_info in port_surfaces.items():
            if port_info.get("type") == "cpw":
                n_elems = len(port_info.get("elements", []))
                if n_elems < 2:
                    errors.append(
                        f"CPW port '{port_name}' has {n_elems} elements "
                        f"(expected >= 2)."
                    )

    # Check absorbing boundary
    if not groups.get("boundary_surfaces", {}).get("absorbing"):
        warnings_list.append(
            "No absorbing boundary found. This is expected if airbox_margin=0."
        )

    # Validate config.json if it exists
    output_dir = getattr(self, "_output_dir", None)
    if output_dir is not None:
        import json

        config_path = output_dir / "config.json"
        if config_path.exists():
            try:
                config = json.loads(config_path.read_text())
                boundaries = config.get("Boundaries", {})
                if not boundaries.get("Conductivity") and not boundaries.get("PEC"):
                    errors.append(
                        "config.json has no Conductivity or PEC boundaries."
                    )
                if not boundaries.get("LumpedPort"):
                    errors.append("config.json has no LumpedPort entries.")
            except json.JSONDecodeError as e:
                errors.append(f"config.json is invalid JSON: {e}")

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

Geometry

Bases: BaseModel

Shared geometry wrapper for gdsfactory Component.

This class wraps a gdsfactory Component and provides computed properties that are useful for simulation setup (bounds, ports, etc.).

Attributes:

Name Type Description
component Any

The wrapped gdsfactory Component

Example

from gdsfactory.components import straight c = straight(length=100) geom = Geometry(component=c) print(geom.bounds) (0.0, -0.25, 100.0, 0.25)

Methods:

Name Description
get_port

Get a port by name.

bounds cached property

bounds: tuple[float, float, float, float]

Get bounding box (xmin, ymin, xmax, ymax) in um.

height property

height: float

Get height (y-extent) of geometry in um.

port_names cached property

port_names: list[str]

Get list of port names.

ports cached property

ports: list

Get list of ports on the component.

width property

width: float

Get width (x-extent) of geometry in um.

get_port

get_port(name: str) -> Any

Get a port by name.

Parameters:

Name Type Description Default
name str

Port name to find

required

Returns:

Type Description
Any

Port object if found, None otherwise

Source code in src/gsim/common/geometry.py
def get_port(self, name: str) -> Any:
    """Get a port by name.

    Args:
        name: Port name to find

    Returns:
        Port object if found, None otherwise
    """
    for port in self.component.ports:
        if port.name == name:
            return port
    return None

GeometryConfig

Bases: BaseModel

Configuration for geometry settings.

This model stores metadata about the component being simulated. The actual Component object is stored separately on simulation classes.

Attributes:

Name Type Description
component_name str | None

Name of the component being simulated

bounds tuple[float, float, float, float] | None

Bounding box as (xmin, ymin, xmax, ymax)

GroundPlane dataclass

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

Ground plane configuration for microstrip structures.

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."""
    d = {
        "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,
    }
    if self.sidewall_angle != 0.0:
        d["sidewall_angle"] = self.sidewall_angle
    return d

LayerStack

Bases: BaseModel

Complete layer stack for Palace simulation.

Methods:

Name Description
from_layer_list

Build a LayerStack from a list of Layer objects.

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.

from_layer_list classmethod

from_layer_list(layerList: list[Layer]) -> LayerStack

Build a LayerStack from a list of Layer objects.

Parameters:

Name Type Description Default
layerList list[Layer]

List of Layer definitions to include in the stack.

required

Returns:

Type Description
LayerStack

A LayerStack with layers/materials/dielectrics assembled from layerList.

Raises:

Type Description
ValueError

If layerList is None or empty.

Source code in src/gsim/common/stack/extractor.py
@classmethod
def from_layer_list(cls, layerList: list[Layer]) -> LayerStack:
    """Build a LayerStack from a list of Layer objects.

    Args:
        layerList: List of Layer definitions to include in the stack.

    Returns:
        A LayerStack with layers/materials/dielectrics assembled from `layerList`.

    Raises:
        ValueError: If `layerList` is None or empty.
    """
    if not layerList:
        raise ValueError("None or empty layer list")
    # Build Layer dict
    layer_dict = {}
    for layer in layerList:
        if layer.name in layer_dict:
            raise ValueError(f"Duplicate layer name: {layer.name}")
        layer_dict[layer.name] = layer
    # Build materials dict
    material_dict = {}
    for layer in layerList:
        material_dict[layer.material] = MATERIALS_DB[layer.material].to_dict()
    # Build dielectric list
    dielectric_list = []
    for layer in layerList:
        if layer.layer_type == "dielectric":
            dielectric = {
                "name": layer.name,
                "zmin": layer.zmin,
                "zmax": layer.zmax,
                "material": layer.material,
            }
            dielectric_list.append(dielectric)
    # Create layer stack and export to YAML
    layer_stack = LayerStack(
        layers=layer_dict, materials=material_dict, dielectrics=dielectric_list
    )
    return layer_stack

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)

MagnetostaticConfig

Bases: BaseModel

Configuration for magnetostatic (inductance matrix) simulation.

Attributes:

Name Type Description
save_fields int

Number of field solutions to save

Methods:

Name Description
to_palace_config

Convert to Palace JSON config format.

to_palace_config

to_palace_config() -> dict

Convert to Palace JSON config format.

Source code in src/gsim/palace/models/problems.py
def to_palace_config(self) -> dict:
    """Convert to Palace JSON config format."""
    return {
        "Save": self.save_fields,
    }

MaterialConfig

Bases: BaseModel

EM properties for a material.

Used for material property overrides in simulation classes.

Attributes:

Name Type Description
type Literal['conductor', 'dielectric', 'semiconductor']

Material type (conductor, dielectric, or semiconductor)

conductivity float | None

Conductivity in S/m (for conductors)

permittivity float | None

Relative permittivity (for dielectrics)

loss_tangent float | None

Dielectric loss tangent

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) -> Self

Create a conductor material.

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

dielectric classmethod

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

Create a dielectric material.

Source code in src/gsim/palace/models/stack.py
@classmethod
def dielectric(cls, permittivity: float, loss_tangent: float = 0.0) -> Self:
    """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/palace/models/stack.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

MaterialProperties

Bases: BaseModel

EM properties for a material.

Methods:

Name Description
conductor

Create a conductor material.

dielectric

Create a dielectric material.

optical

Create a material with optical properties for photonic simulation.

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
    )

optical classmethod

optical(refractive_index: float, extinction_coeff: float = 0.0) -> MaterialProperties

Create a material with optical properties for photonic simulation.

Parameters:

Name Type Description Default
refractive_index float

Refractive index (n)

required
extinction_coeff float

Extinction coefficient (k), default 0

0.0
Source code in src/gsim/common/stack/materials.py
@classmethod
def optical(
    cls,
    refractive_index: float,
    extinction_coeff: float = 0.0,
) -> MaterialProperties:
    """Create a material with optical properties for photonic simulation.

    Args:
        refractive_index: Refractive index (n)
        extinction_coeff: Extinction coefficient (k), default 0
    """
    return cls(
        type="dielectric",
        refractive_index=refractive_index,
        extinction_coeff=extinction_coeff,
    )

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
    if self.permittivity_diagonal is not None:
        d["permittivity_diagonal"] = self.permittivity_diagonal
    if self.permeability is not None:
        d["permeability"] = self.permeability
    if self.loss_tangent_diagonal is not None:
        d["loss_tangent_diagonal"] = self.loss_tangent_diagonal
    if self.material_axes is not None:
        d["material_axes"] = self.material_axes
    if self.refractive_index is not None:
        d["refractive_index"] = self.refractive_index
    if self.extinction_coeff is not None:
        d["extinction_coeff"] = self.extinction_coeff
    return d

MeshConfig dataclass

MeshConfig(
    refined_mesh_size: float = 5.0,
    max_mesh_size: float = 300.0,
    cells_per_wavelength: int = 10,
    margin: float = 50.0,
    airbox_margin: float = 0.0,
    ground_plane: GroundPlane | None = None,
    fmax: float = 100000000000.0,
    boundary_conditions: list[str] | None = None,
    planar_conductors: bool = False,
    refine_from_curves: bool = False,
    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.graded() - Default sizes + refined near conductor edges 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).

graded

Default mesh sizes with refinement near conductor edges.

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,
        refine_from_curves=True,
        **kwargs,
    )

graded classmethod

graded(**kwargs) -> MeshConfig

Default mesh sizes with refinement near conductor edges.

Source code in src/gsim/palace/mesh/pipeline.py
@classmethod
def graded(cls, **kwargs) -> MeshConfig:
    """Default mesh sizes with refinement near conductor edges."""
    refined, max_size, cpw = _MESH_PRESETS[MeshPreset.GRADED]
    return cls(
        refined_mesh_size=refined,
        max_mesh_size=max_size,
        cells_per_wavelength=cpw,
        refine_from_curves=True,
        **kwargs,
    )

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

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.

NumericalConfig

Bases: BaseModel

Numerical solver configuration.

Attributes:

Name Type Description
order int

Finite element order (1-4)

tolerance float

Linear solver tolerance

max_iterations int

Maximum solver iterations

solver_type Literal['Default', 'SuperLU', 'STRUMPACK', 'MUMPS']

Linear solver type

preconditioner Literal['Default', 'AMS', 'BoomerAMG']

Preconditioner type

device Literal['CPU', 'GPU']

Compute device (CPU or GPU)

num_processors int | None

Number of processors (None = auto)

Methods:

Name Description
to_palace_config

Convert to Palace JSON config format.

to_palace_config

to_palace_config() -> dict

Convert to Palace JSON config format.

Source code in src/gsim/palace/models/numerical.py
def to_palace_config(self) -> dict:
    """Convert to Palace JSON config format."""
    solver_config: dict[str, str | int | float] = {
        "Tolerance": self.tolerance,
        "MaxIterations": self.max_iterations,
    }

    if self.solver_type != "Default":
        solver_config["Type"] = self.solver_type

    if self.preconditioner != "Default":
        solver_config["Preconditioner"] = self.preconditioner

    return {
        "Order": self.order,
        "Solver": solver_config,
    }

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,
    resistance: float | None = None,
    inductance: float | None = None,
    capacitance: float | None = None,
    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.

PortConfig

Bases: BaseModel

Configuration for a single-element lumped port.

Lumped ports can be inplane (horizontal, on single layer) or via (vertical, between two layers).

Attributes:

Name Type Description
name str

Port name (must match component port name)

layer str | None

Target layer for inplane ports

from_layer str | None

Bottom layer for via ports

to_layer str | None

Top layer for via ports

length float | None

Port extent along direction (um)

impedance float

Port impedance (Ohms)

excited bool

Whether this port is excited

geometry Literal['inplane', 'via']

Port geometry type ("inplane" or "via")

Methods:

Name Description
validate_layer_config

Validate layer configuration based on geometry type.

validate_layer_config

validate_layer_config() -> Self

Validate layer configuration based on geometry type.

Source code in src/gsim/palace/models/ports.py
@model_validator(mode="after")
def validate_layer_config(self) -> Self:
    """Validate layer configuration based on geometry type."""
    if self.geometry == "inplane" and self.layer is None:
        raise ValueError("Inplane ports require 'layer' to be specified")
    if self.geometry == "via" and (
        self.from_layer is None or self.to_layer is None
    ):
        raise ValueError("Via ports require both 'from_layer' and 'to_layer'")
    return self

PortGeometry

Bases: Enum

Internal geometry type for mesh generation.

PortType

Bases: Enum

Palace port types (maps to Palace config).

SimulationResult

Bases: BaseModel

Result from running a Palace simulation.

Attributes:

Name Type Description
mesh_path Path

Path to the generated mesh file

output_dir Path

Output directory path

config_path Path | None

Path to the Palace config file

results dict[str, Path]

Dictionary mapping result filenames to paths

conductor_groups dict

Physical group info for conductors

dielectric_groups dict

Physical group info for dielectrics

port_groups dict

Physical group info for ports

boundary_groups dict

Physical group info for boundaries

port_info list

Port metadata

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.

TerminalConfig

Bases: BaseModel

Configuration for a terminal (for electrostatic capacitance extraction).

Terminals define conductor surfaces for capacitance matrix extraction in electrostatic simulations.

Attributes:

Name Type Description
name str

Terminal name

layer str

Target conductor layer

TransientConfig

Bases: BaseModel

Configuration for transient (time-domain) simulation.

Attributes:

Name Type Description
max_time float

Maximum simulation time in ns

excitation Literal['sinusoidal', 'gaussian', 'ramp', 'smoothstep']

Excitation waveform type

excitation_freq float | None

Excitation frequency in Hz (for sinusoidal)

excitation_width float | None

Pulse width in ns (for gaussian)

time_step float | None

Time step in ns (None = adaptive)

Methods:

Name Description
to_palace_config

Convert to Palace JSON config format.

to_palace_config

to_palace_config() -> dict

Convert to Palace JSON config format.

Source code in src/gsim/palace/models/problems.py
def to_palace_config(self) -> dict:
    """Convert to Palace JSON config format."""
    config: dict = {
        "Type": self.excitation.capitalize(),
        "MaxTime": self.max_time,
    }
    if self.excitation_freq is not None:
        config["ExcitationFreq"] = self.excitation_freq / 1e9  # Convert to GHz
    if self.excitation_width is not None:
        config["ExcitationWidth"] = self.excitation_width
    if self.time_step is not None:
        config["TimeStep"] = self.time_step
    return config

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

WavePortConfig

Bases: BaseModel

Configuration for a wave port (domain boundary with mode solving).

Wave ports are used for domain-boundary ports where mode solving is needed. This is an alternative to lumped ports for more accurate S-parameter extraction.

Attributes:

Name Type Description
name str

Port name (must match component port name)

layer str

Target conductor layer

mode int

Mode number to excite

excited bool

Whether this port is excited

offset float

De-embedding distance in um

Functions

configure_cpw_port

configure_cpw_port(
    port,
    layer: str,
    s_width: float,
    gap_width: float,
    length: float,
    impedance: float = 50.0,
    excited: bool = True,
    offset: float = 0.0,
)

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

In CPW (Ground-Signal-Ground), E-fields are opposite in the two gaps. The port should be placed at the signal center. The upper and lower gap centers are computed from the signal width and gap width.

Parameters:

Name Type Description Default
port

gdsfactory Port at the signal center

required
layer str

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

required
s_width float

Signal conductor width in um

required
gap_width float

Gap width between signal and ground in um

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
offset float

Shift port inward along the waveguide (um). Positive moves away from the boundary, into the conductor.

0.0

Examples:

configure_cpw_port(
    c.ports["o1"],
    layer="topmetal2",
    s_width=10.0,
    gap_width=6.0,
    length=5.0,
)
Source code in src/gsim/palace/ports/config.py
def configure_cpw_port(
    port,
    layer: str,
    s_width: float,
    gap_width: float,
    length: float,
    impedance: float = 50.0,
    excited: bool = True,
    offset: float = 0.0,
):
    """Configure a gdsfactory port as a CPW (multi-element) lumped port.

    In CPW (Ground-Signal-Ground), E-fields are opposite in the two gaps.
    The port should be placed at the signal center. The upper and lower gap
    centers are computed from the signal width and gap width.

    Args:
        port: gdsfactory Port at the signal center
        layer: Target conductor layer name (e.g., 'topmetal2')
        s_width: Signal conductor width in um
        gap_width: Gap width between signal and ground in um
        length: Port extent along direction (um)
        impedance: Port impedance in Ohms (default: 50)
        excited: Whether port is excited (default: True)
        offset: Shift port inward along the waveguide (um).
            Positive moves away from the boundary, into the conductor.

    Examples:
        ```python
        configure_cpw_port(
            c.ports["o1"],
            layer="topmetal2",
            s_width=10.0,
            gap_width=6.0,
            length=5.0,
        )
        ```
    """
    import numpy as np

    center = np.array([float(port.center[0]), float(port.center[1])])
    orientation_rad = np.deg2rad(
        float(port.orientation) if port.orientation is not None else 0.0
    )

    # Longitudinal direction (along waveguide / port orientation)
    # Port orientation points *outward* from the component, so we
    # negate it: positive offset moves the port *inward* along the
    # waveguide (away from the boundary, into the conductor).
    longitudinal = np.array([np.cos(orientation_rad), np.sin(orientation_rad)])

    # Apply longitudinal offset if requested
    if offset != 0.0:
        center = center - longitudinal * offset

    # Transverse direction (perpendicular to port orientation, in-plane)
    # Port orientation points along the waveguide; transverse is 90° CCW
    transverse = np.array([-np.sin(orientation_rad), np.cos(orientation_rad)])

    # Gap center offset from signal center
    gap_offset = (s_width + gap_width) / 2.0

    upper_center = center + transverse * gap_offset
    lower_center = center - transverse * gap_offset

    # Store computed CPW element info on the single port
    port.info["palace_type"] = "cpw"
    port.info["layer"] = layer
    port.info["length"] = length
    port.info["impedance"] = impedance
    port.info["excited"] = excited
    port.info["cpw_upper_center"] = (float(upper_center[0]), float(upper_center[1]))
    port.info["cpw_lower_center"] = (float(lower_center[0]), float(lower_center[1]))
    port.info["cpw_gap_width"] = gap_width

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

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 or "unknown"
        gds_layer = get_gds_layer_tuple(layer_level) or (0, 0)
        layer_type = classify_layer_type(layer_name, material)
        sidewall_angle = getattr(layer_level, "sidewall_angle", 0.0) or 0.0

        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,
            sidewall_angle=float(sidewall_angle),
        )

        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

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).

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).

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

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

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

        if palace_type is None:
            continue

        if palace_type == "cpw":
            # Single-port CPW: gap centers were pre-computed by configure_cpw_port
            layer_name = info.get("layer")
            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

            upper_center = info["cpw_upper_center"]
            lower_center = info["cpw_lower_center"]
            gap_width = info["cpw_gap_width"]

            centers = [
                (float(upper_center[0]), float(upper_center[1])),
                (float(lower_center[0]), float(lower_center[1])),
            ]
            # Upper element: E-field toward signal (negative transverse)
            # Lower element: E-field toward signal (positive transverse)
            directions = ["-Y", "+Y"]

            cpw_port = PalacePort(
                name=port.name,
                port_type=PortType.LUMPED,
                geometry=PortGeometry.INPLANE,
                center=(float(port.center[0]), float(port.center[1])),
                width=gap_width,
                orientation=float(port.orientation)
                if port.orientation is not None
                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)
            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),
            resistance=info.get("resistance"),
            inductance=info.get("inductance"),
            capacitance=info.get("capacitance"),
            excited=info.get("excited", True),
        )
        palace_ports.append(palace_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.airbox_margin,
        fmax=config.fmax,
        show_gui=config.show_gui,
        driven_config=driven_config,
        write_config=write_config,
        planar_conductors=config.planar_conductors,
        refine_from_curves=config.refine_from_curves,
    )

    # 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,
    )

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

get_stack

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

Get layer stack from active PDK or YAML file.

Automatically detects the active PDK and builds the appropriate stack by extracting layers from the PDK's LAYER_STACK via :func:extract_from_pdk.

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 the stack builder: - 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.

    Automatically detects the active PDK and builds the appropriate stack
    by extracting layers from the PDK's ``LAYER_STACK`` via
    :func:`extract_from_pdk`.

    Args:
        yaml_path: Path to custom YAML stack file. If None, uses active PDK.
        **kwargs: Additional args passed to the stack builder:
            - 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

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"

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 or None
        tup = get_gds_layer_tuple(level)
        gds_layer = tup[0] if tup else None
        layer_type = classify_layer_type(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

plot_mesh

plot_mesh(
    msh_path: str | Path,
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None

Plot a .msh mesh using PyVista.

Two rendering styles are available:

  • wireframe (default) — edges only, one colour per group when show_groups is given; black otherwise.
  • solid — coloured surfaces per physical group with a legend bar. Groups listed in transparent_groups are drawn with low opacity so the interior structure remains visible.

Parameters:

Name Type Description Default
msh_path str | Path

Path to .msh file.

required
output str | Path | None

Output PNG path (only used when interactive=False).

None
show_groups list[str] | None

Group-name patterns to display (None → all). Example: ["metal", "P"] to show metal layers and ports.

None
interactive bool

If True, open an interactive 3-D viewer. If False, save a static PNG to output.

True
style Literal['wireframe', 'solid']

"wireframe" or "solid".

'wireframe'
transparent_groups list[str] | None

Group names rendered at low opacity in solid mode. Ignored in wireframe mode.

None
Example

pa.plot_mesh("./sim/palace.msh", show_groups=["metal", "P"]) pa.plot_mesh( ... "sim.msh", style="solid", transparent_groups=["Absorbing_boundary"] ... )

Source code in src/gsim/viz.py
def plot_mesh(
    msh_path: str | Path,
    output: str | Path | None = None,
    show_groups: list[str] | None = None,
    interactive: bool = True,
    style: Literal["wireframe", "solid"] = "wireframe",
    transparent_groups: list[str] | None = None,
) -> None:
    """Plot a ``.msh`` mesh using PyVista.

    Two rendering styles are available:

    * **wireframe** (default) — edges only, one colour per group when
      *show_groups* is given; black otherwise.
    * **solid** — coloured surfaces per physical group with a legend
      bar.  Groups listed in *transparent_groups* are drawn with low
      opacity so the interior structure remains visible.

    Args:
        msh_path: Path to ``.msh`` file.
        output: Output PNG path (only used when ``interactive=False``).
        show_groups: Group-name patterns to display (``None`` → all).
            Example: ``["metal", "P"]`` to show metal layers and ports.
        interactive: If ``True``, open an interactive 3-D viewer.
            If ``False``, save a static PNG to *output*.
        style: ``"wireframe"`` or ``"solid"``.
        transparent_groups: Group names rendered at low opacity in
            *solid* mode.  Ignored in *wireframe* mode.

    Example:
        >>> pa.plot_mesh("./sim/palace.msh", show_groups=["metal", "P"])
        >>> pa.plot_mesh(
        ...     "sim.msh", style="solid", transparent_groups=["Absorbing_boundary"]
        ... )
    """
    msh_path = Path(msh_path)

    if style == "solid":
        _plot_solid(
            msh_path,
            output=output,
            interactive=interactive,
            transparent_groups=transparent_groups or [],
        )
    else:
        _plot_wireframe(
            msh_path,
            output=output,
            show_groups=show_groups,
            interactive=interactive,
        )

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_job_summary

print_job_summary(job) -> None

Print a formatted summary of a simulation job.

Parameters:

Name Type Description Default
job

Job object from gdsfactoryplus

required
Source code in src/gsim/gcloud.py
def print_job_summary(job) -> None:
    """Print a formatted summary of a simulation job.

    Args:
        job: Job object from gdsfactoryplus
    """
    if job.started_at and job.finished_at:
        delta = job.finished_at - job.started_at
        minutes, seconds = divmod(int(delta.total_seconds()), 60)
        duration = f"{minutes}m {seconds}s"
    else:
        duration = "N/A"

    size_kb = job.output_size_bytes / 1024
    size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.2f} MB"
    files = list(job.download_urls.keys()) if job.download_urls else []

    print(f"{'Job:':<12} {job.job_name}")  # noqa: T201
    print(f"{'Status:':<12} {job.status.value} (exit {job.exit_code})")  # noqa: T201
    print(f"{'Duration:':<12} {duration}")  # noqa: T201
    mem_gb = job.requested_memory_mb // 1024
    print(f"{'Resources:':<12} {job.requested_cpu} CPU / {mem_gb} GB")  # noqa: T201
    print(f"{'Output:':<12} {size_str}")  # noqa: T201
    print(f"{'Files:':<12} {len(files)} files")  # noqa: T201
    for f in files:
        print(f"             - {f}")  # noqa: T201

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

run_simulation module-attribute

run_simulation = partial(run_simulation, job_type='palace')

Data

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, refractive_index=1.44
    ),
    "passive": MaterialProperties(
        type="dielectric", permittivity=6.6, loss_tangent=0.0
    ),
    "Si3N4": MaterialProperties(
        type="dielectric", permittivity=7.5, loss_tangent=0.001, refractive_index=2.0
    ),
    "polyimide": MaterialProperties(
        type="dielectric", permittivity=3.4, loss_tangent=0.002
    ),
    "air": MaterialProperties(
        type="dielectric", permittivity=1.0, loss_tangent=0.0, refractive_index=1.0
    ),
    "vacuum": MaterialProperties(
        type="dielectric", permittivity=1.0, loss_tangent=0.0, refractive_index=1.0
    ),
    "silicon": MaterialProperties(
        type="semiconductor", permittivity=11.9, conductivity=2.0, refractive_index=3.47
    ),
    "si": MaterialProperties(
        type="semiconductor", permittivity=11.9, conductivity=2.0, refractive_index=3.47
    ),
    "sapphire": MaterialProperties(
        type="dielectric",
        permittivity=9.3,
        loss_tangent=3e-05,
        refractive_index=1.77,
        permittivity_diagonal=[9.3, 9.3, 11.5],
        permeability=[0.99999975, 0.99999975, 0.99999979],
        loss_tangent_diagonal=[3e-05, 3e-05, 8.6e-05],
        material_axes=[[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]],
    ),
    "quartz": MaterialProperties(type="dielectric", permittivity=4.5),
    "tfln": MaterialProperties(type="dielectric", permittivity=44.0),
}

gsim.meep

Classes

BuildResult dataclass

BuildResult(config: Any, component: Any, original_component: Any)

Result of :meth:Simulation.build_config — single source of truth.

Attributes:

Name Type Description
config Any

Full serializable SimConfig.

component Any

Extended component (what meep actually simulates).

original_component Any

Original component before port extension.

Domain

Bases: BaseModel

Computational domain sizing: PML + margins + symmetries.

DomainConfig

Bases: BaseModel

Simulation domain sizing: margins around geometry + PML thickness.

Margins control how much material (from the layer stack) is kept around the waveguide core. set_z_crop() uses margin_z_above / margin_z_below to determine the crop window. Along XY the margin is the gap between the geometry bounding-box and the PML inner edge.

Cell size formula

cell_x = bbox_width + 2(margin_xy + dpml) cell_y = bbox_height + 2(margin_xy + dpml) cell_z = z_extent + 2*dpml (z-margins baked into z_extent)

FDTD

Bases: BaseModel

Solver numerics: resolution, stopping, subpixel, diagnostics.

Methods:

Name Description
stop_after_sources

Run for a fixed sim-time after sources turn off.

stop_after_walltime

Set a wall-clock time limit (safety net).

stop_when_dft_decayed

Stop when all DFT monitors converge.

stop_when_energy_decayed

Stop when total field energy in the cell decays (recommended).

stop_when_fields_decayed

Stop when a field component decays at a point (recommended).

stop_after_sources

stop_after_sources(time: float) -> FDTD

Run for a fixed sim-time after sources turn off.

Parameters:

Name Type Description Default
time float

Run time after sources in MEEP time units (um/c).

required

Returns:

Type Description
FDTD

self (for fluent chaining).

Source code in src/gsim/meep/models/api.py
def stop_after_sources(self, time: float) -> FDTD:
    """Run for a fixed sim-time after sources turn off.

    Args:
        time: Run time after sources in MEEP time units (um/c).

    Returns:
        self (for fluent chaining).
    """
    self.stopping = "fixed"
    self.max_time = time
    return self

stop_after_walltime

stop_after_walltime(seconds: float) -> FDTD

Set a wall-clock time limit (safety net).

This is orthogonal to the sim-time stopping mode — it caps how long the FDTD run is allowed to take in real (wall) seconds. Combine with any other stopping method.

Parameters:

Name Type Description Default
seconds float

Maximum wall-clock seconds for the FDTD run.

required

Returns:

Type Description
FDTD

self (for fluent chaining).

Source code in src/gsim/meep/models/api.py
def stop_after_walltime(self, seconds: float) -> FDTD:
    """Set a wall-clock time limit (safety net).

    This is orthogonal to the sim-time stopping mode — it caps
    how long the FDTD run is allowed to take in real (wall) seconds.
    Combine with any other stopping method.

    Args:
        seconds: Maximum wall-clock seconds for the FDTD run.

    Returns:
        self (for fluent chaining).
    """
    self.wall_time_max = seconds
    return self

stop_when_dft_decayed

stop_when_dft_decayed(tol: float = 0.001, min_time: float = 100.0) -> FDTD

Stop when all DFT monitors converge.

Parameters:

Name Type Description Default
tol float

DFT convergence tolerance.

0.001
min_time float

Minimum absolute sim time before checking convergence.

100.0

Returns:

Type Description
FDTD

self (for fluent chaining).

Source code in src/gsim/meep/models/api.py
def stop_when_dft_decayed(self, tol: float = 1e-3, min_time: float = 100.0) -> FDTD:
    """Stop when all DFT monitors converge.

    Args:
        tol: DFT convergence tolerance.
        min_time: Minimum absolute sim time before checking convergence.

    Returns:
        self (for fluent chaining).
    """
    self.stopping = "dft_decay"
    self.stopping_threshold = tol
    self.stopping_min_time = min_time
    return self

stop_when_energy_decayed

stop_when_energy_decayed(dt: float = 50.0, decay_by: float = 0.05) -> FDTD

Stop when total field energy in the cell decays (recommended).

Monitors total electromagnetic energy and stops when it has decayed by decay_by from its peak value. More robust than dft_decay for devices where DFTs can falsely converge on near-zero fields.

Parameters:

Name Type Description Default
dt float

Time window between energy checks (MEEP time units).

50.0
decay_by float

Fractional energy decay threshold (e.g. 0.05 = 5%).

0.05

Returns:

Type Description
FDTD

self (for fluent chaining).

Source code in src/gsim/meep/models/api.py
def stop_when_energy_decayed(
    self, dt: float = 50.0, decay_by: float = 0.05
) -> FDTD:
    """Stop when total field energy in the cell decays (recommended).

    Monitors total electromagnetic energy and stops when it has decayed
    by ``decay_by`` from its peak value.  More robust than ``dft_decay``
    for devices where DFTs can falsely converge on near-zero fields.

    Args:
        dt: Time window between energy checks (MEEP time units).
        decay_by: Fractional energy decay threshold (e.g. 0.05 = 5%).

    Returns:
        self (for fluent chaining).
    """
    self.stopping = "energy_decay"
    self.stopping_dt = dt
    self.stopping_threshold = decay_by
    return self

stop_when_fields_decayed

stop_when_fields_decayed(
    dt: float = 50.0,
    component: str = "Ey",
    decay_by: float = 0.05,
    monitor_port: str | None = None,
) -> FDTD

Stop when a field component decays at a point (recommended).

Matches the standard MEEP tutorial stopping condition. Monitors |component|² at a point and stops when it decays by decay_by from its peak value.

Parameters:

Name Type Description Default
dt float

Decay measurement time window.

50.0
component str

Field component name (e.g. "Ey", "Hz").

'Ey'
decay_by float

Fractional decay threshold (e.g. 0.05 = 5%).

0.05
monitor_port str | None

Port to monitor (None = first non-source port).

None

Returns:

Type Description
FDTD

self (for fluent chaining).

Source code in src/gsim/meep/models/api.py
def stop_when_fields_decayed(
    self,
    dt: float = 50.0,
    component: str = "Ey",
    decay_by: float = 0.05,
    monitor_port: str | None = None,
) -> FDTD:
    """Stop when a field component decays at a point (recommended).

    Matches the standard MEEP tutorial stopping condition.  Monitors
    |component|² at a point and stops when it decays by ``decay_by``
    from its peak value.

    Args:
        dt: Decay measurement time window.
        component: Field component name (e.g. "Ey", "Hz").
        decay_by: Fractional decay threshold (e.g. 0.05 = 5%).
        monitor_port: Port to monitor (None = first non-source port).

    Returns:
        self (for fluent chaining).
    """
    self.stopping = "field_decay"
    self.stopping_dt = dt
    self.stopping_component = component
    self.stopping_threshold = decay_by
    self.stopping_monitor_port = monitor_port
    return self

Geometry

Bases: BaseModel

Physical layout: component + layer stack + optional z-crop.

Material

Bases: BaseModel

Optical material properties.

ModeSource

Bases: BaseModel

Mode source excitation and spectral measurement window.

ResolutionConfig

Bases: BaseModel

MEEP grid resolution configuration.

Methods:

Name Description
coarse

Coarse resolution (16 pixels/um) for quick tests.

default

Default resolution (32 pixels/um).

fine

Fine resolution (64 pixels/um) for production runs.

coarse classmethod

coarse() -> ResolutionConfig

Coarse resolution (16 pixels/um) for quick tests.

Source code in src/gsim/meep/models/config.py
@classmethod
def coarse(cls) -> ResolutionConfig:
    """Coarse resolution (16 pixels/um) for quick tests."""
    return cls(pixels_per_um=16)

default classmethod

default() -> ResolutionConfig

Default resolution (32 pixels/um).

Source code in src/gsim/meep/models/config.py
@classmethod
def default(cls) -> ResolutionConfig:
    """Default resolution (32 pixels/um)."""
    return cls(pixels_per_um=32)

fine classmethod

fine() -> ResolutionConfig

Fine resolution (64 pixels/um) for production runs.

Source code in src/gsim/meep/models/config.py
@classmethod
def fine(cls) -> ResolutionConfig:
    """Fine resolution (64 pixels/um) for production runs."""
    return cls(pixels_per_um=64)

SParameterResult

Bases: BaseModel

S-parameter results from MEEP simulation.

Parses CSV output from the cloud runner and provides visualization via matplotlib.

Methods:

Name Description
from_csv

Parse S-parameter results from CSV file.

from_directory

Load from directory — handles preview-only with no CSV.

plot

Plot S-parameters vs wavelength.

show_animation

Display field animation MP4 in Jupyter.

show_diagnostics

Display diagnostic images in Jupyter.

from_csv classmethod

from_csv(path: str | Path) -> SParameterResult

Parse S-parameter results from CSV file.

Expected CSV format

wavelength,S11_mag,S11_phase,S21_mag,S21_phase,... 1.5, 0.1, -30.0, 0.9, 45.0, ...

Automatically loads meep_debug.json from the same directory if it exists, populating the debug_info field.

Parameters:

Name Type Description Default
path str | Path

Path to CSV file

required

Returns:

Type Description
SParameterResult

SParameterResult instance

Source code in src/gsim/meep/models/results.py
@classmethod
def from_csv(cls, path: str | Path) -> SParameterResult:
    """Parse S-parameter results from CSV file.

    Expected CSV format:
        wavelength,S11_mag,S11_phase,S21_mag,S21_phase,...
        1.5, 0.1, -30.0, 0.9, 45.0, ...

    Automatically loads ``meep_debug.json`` from the same directory
    if it exists, populating the ``debug_info`` field.

    Args:
        path: Path to CSV file

    Returns:
        SParameterResult instance
    """
    import cmath

    path = Path(path)
    wavelengths: list[float] = []
    s_params: dict[str, list[complex]] = {}
    port_names: set[str] = set()

    with open(path) as f:
        reader = csv.DictReader(f)
        if reader.fieldnames is None:
            return cls()

        # Discover S-param columns
        sparam_names: list[str] = []
        for col in reader.fieldnames:
            if col.endswith("_mag"):
                name = col.removesuffix("_mag")
                sparam_names.append(name)
                s_params[name] = []

        for row in reader:
            wavelengths.append(float(row["wavelength"]))
            for name in sparam_names:
                mag = float(row[f"{name}_mag"])
                phase_deg = float(row[f"{name}_phase"])
                phase_rad = cmath.pi * phase_deg / 180.0
                s_params[name].append(cmath.rect(mag, phase_rad))

    # Extract port names from S-param names (e.g., "S11" -> port 1)
    for name in sparam_names:
        # S-param format: "Sij" where i,j are port indices
        if len(name) >= 3 and name[0] == "S":
            indices = name[1:]
            for idx_char in indices:
                port_names.add(f"port_{idx_char}")

    # Auto-load debug log if present alongside CSV
    debug_info: dict[str, Any] = {}
    debug_path = path.parent / "meep_debug.json"
    if debug_path.exists():
        with contextlib.suppress(json.JSONDecodeError, OSError):
            debug_info = json.loads(debug_path.read_text())

    # Auto-detect diagnostic PNGs
    diagnostic_images: dict[str, str] = {}
    for key, filename in [
        ("geometry_xy", "meep_geometry_xy.png"),
        ("geometry_xz", "meep_geometry_xz.png"),
        ("geometry_yz", "meep_geometry_yz.png"),
        ("fields_xy", "meep_fields_xy.png"),
        ("animation", "meep_animation.mp4"),
    ]:
        img_path = path.parent / filename
        if img_path.exists():
            diagnostic_images[key] = str(img_path)

    # Detect animation frame PNGs
    frame_pngs = sorted(path.parent.glob("meep_frame_*.png"))
    if frame_pngs:
        diagnostic_images["animation_frames"] = str(path.parent)

    return cls(
        wavelengths=wavelengths,
        s_params=s_params,
        port_names=sorted(port_names),
        debug_info=debug_info,
        diagnostic_images=diagnostic_images,
    )

from_directory classmethod

from_directory(directory: str | Path) -> SParameterResult

Load from directory — handles preview-only with no CSV.

If s_parameters.csv exists, delegates to from_csv(). Otherwise loads only debug info and diagnostic images (preview mode).

Parameters:

Name Type Description Default
directory str | Path

Path to results directory

required

Returns:

Type Description
SParameterResult

SParameterResult instance

Source code in src/gsim/meep/models/results.py
@classmethod
def from_directory(cls, directory: str | Path) -> SParameterResult:
    """Load from directory — handles preview-only with no CSV.

    If ``s_parameters.csv`` exists, delegates to ``from_csv()``.
    Otherwise loads only debug info and diagnostic images (preview mode).

    Args:
        directory: Path to results directory

    Returns:
        SParameterResult instance
    """
    directory = Path(directory)
    csv_path = directory / "s_parameters.csv"
    if csv_path.exists():
        return cls.from_csv(csv_path)

    # Preview-only: load debug + images only
    debug_info: dict[str, Any] = {}
    debug_path = directory / "meep_debug.json"
    if debug_path.exists():
        with contextlib.suppress(json.JSONDecodeError, OSError):
            debug_info = json.loads(debug_path.read_text())

    diagnostic_images: dict[str, str] = {}
    for key, filename in [
        ("geometry_xy", "meep_geometry_xy.png"),
        ("geometry_xz", "meep_geometry_xz.png"),
        ("geometry_yz", "meep_geometry_yz.png"),
        ("fields_xy", "meep_fields_xy.png"),
        ("animation", "meep_animation.mp4"),
    ]:
        img_path = directory / filename
        if img_path.exists():
            diagnostic_images[key] = str(img_path)

    # Detect animation frame PNGs
    frame_pngs = sorted(directory.glob("meep_frame_*.png"))
    if frame_pngs:
        diagnostic_images["animation_frames"] = str(directory)

    return cls(
        debug_info=debug_info,
        diagnostic_images=diagnostic_images,
    )

plot

plot(db: bool = True, **kwargs: Any) -> Any

Plot S-parameters vs wavelength.

Parameters:

Name Type Description Default
db bool

If True, plot in dB scale

True
**kwargs Any

Passed to matplotlib plot()

{}

Returns:

Type Description
Any

matplotlib Figure

Source code in src/gsim/meep/models/results.py
def plot(self, db: bool = True, **kwargs: Any) -> Any:
    """Plot S-parameters vs wavelength.

    Args:
        db: If True, plot in dB scale
        **kwargs: Passed to matplotlib plot()

    Returns:
        matplotlib Figure
    """
    import matplotlib.pyplot as plt

    fig, ax = plt.subplots()
    plt.close(fig)  # prevent double display in notebooks

    ylabel = "|S| (dB)" if db else "|S|"
    for name, values in self.s_params.items():
        magnitudes = [abs(v) for v in values]
        if db:
            import math

            y_vals = [20 * math.log10(m) if m > 0 else -100 for m in magnitudes]
        else:
            y_vals = magnitudes

        ax.plot(self.wavelengths, y_vals, ".-", label=name, **kwargs)

    ax.set_xlabel("Wavelength (um)")
    ax.set_ylabel(ylabel)
    ax.legend()
    ax.grid(True, alpha=0.3)
    ax.set_title("S-Parameters")
    fig.tight_layout()

    return fig

show_animation

show_animation() -> None

Display field animation MP4 in Jupyter.

Source code in src/gsim/meep/models/results.py
def show_animation(self) -> None:
    """Display field animation MP4 in Jupyter."""
    mp4_path = self.diagnostic_images.get("animation")
    if mp4_path is None:
        logger.info("No animation MP4 available.")
        return

    from IPython.display import Video, display

    display(Video(mp4_path, embed=True, mimetype="video/mp4"))

show_diagnostics

show_diagnostics() -> None

Display diagnostic images in Jupyter.

Source code in src/gsim/meep/models/results.py
def show_diagnostics(self) -> None:
    """Display diagnostic images in Jupyter."""
    from IPython.display import Image, display

    if not self.diagnostic_images:
        logger.info("No diagnostic images available.")
        return

    for name, img_path in sorted(self.diagnostic_images.items()):
        if not img_path.endswith((".png", ".jpg", ".jpeg", ".gif")):
            continue  # skip video/directories; use show_animation() for MP4
        logger.info("--- %s ---", name)
        display(Image(filename=img_path))

SimConfig

Bases: BaseModel

Complete serializable simulation config written as JSON.

This is the top-level config that the cloud MEEP runner reads. The geometry is NOT included here — it's in the GDS file. The layer_stack tells the runner how to extrude each GDS layer.

Methods:

Name Description
to_json

Write config to JSON file.

to_json

to_json(path: str | Path) -> Path

Write config to JSON file.

Parameters:

Name Type Description Default
path str | Path

Output file path

required

Returns:

Type Description
Path

Path to the written file

Source code in src/gsim/meep/models/config.py
def to_json(self, path: str | Path) -> Path:
    """Write config to JSON file.

    Args:
        path: Output file path

    Returns:
        Path to the written file
    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(self.model_dump(by_alias=True), indent=2))
    return path

Simulation

Bases: BaseModel

Declarative MEEP FDTD simulation container.

Assigns typed physics objects, then calls write_config() to produce the JSON + GDS + runner consumed by the cloud engine.

Example::

from gsim import meep

sim = meep.Simulation()
sim.geometry.component = ybranch
sim.geometry.stack = stack
sim.materials = {"si": 3.47, "sio2": 1.44}
sim.source.port = "o1"
sim.monitors = ["o1", "o2"]
sim.solver.stopping = "dft_decay"
sim.solver.max_time = 200
result = sim.run()  # creates sim-data-{job_name}/ in CWD

Methods:

Name Description
build_config

Build the complete simulation config (single source of truth).

get_status

Get the current status of this sim's cloud job.

plot_2d

Plot 2D cross-sections of the geometry.

plot_3d

Plot 3D visualization of the geometry.

run

Run MEEP simulation on the cloud.

start

Start cloud execution for this sim's uploaded job.

upload

Write config and upload to the cloud. Does NOT start execution.

validate_config

Validate the simulation configuration.

wait_for_results

Wait for this sim's cloud job, download and parse results.

write_config

Serialize simulation config to output directory.

build_config

build_config() -> BuildResult

Build the complete simulation config (single source of truth).

All computation — validation, stack resolution, z-crop, port extension, material resolution, MPI estimation — happens here. Both :meth:write_config and the viz methods consume this output.

Returns:

Type Description
BuildResult

BuildResult with SimConfig, extended component, and original.

Raises:

Type Description
ValueError

If config is invalid.

Source code in src/gsim/meep/simulation.py
def build_config(self) -> BuildResult:
    """Build the complete simulation config (single source of truth).

    All computation — validation, stack resolution, z-crop, port
    extension, material resolution, MPI estimation — happens here.
    Both :meth:`write_config` and the viz methods consume this output.

    Returns:
        BuildResult with SimConfig, extended component, and original.

    Raises:
        ValueError: If config is invalid.
    """
    from gsim.meep.materials import resolve_materials
    from gsim.meep.models.config import LayerStackEntry, SimConfig, SymmetryEntry
    from gsim.meep.ports import extract_port_info

    validation = self.validate_config()
    if not validation.valid:
        raise ValueError("Invalid configuration:\n" + "\n".join(validation.errors))

    # Resolve stack
    self._ensure_stack()
    if self.geometry.stack is None:
        raise ValueError("Stack resolution failed.")
    if self.geometry.component is None:
        raise ValueError("No geometry set.")

    # Apply z-crop if requested
    self._apply_z_crop()

    import gdsfactory as gf

    original_component = self.geometry.component.copy()
    stack = self.geometry.stack

    # Build config objects
    domain_cfg = self._domain_config()
    wl_cfg = self._wavelength_config()
    source_cfg = self._source_config()
    stopping_cfg = self._stopping_config()
    resolution_cfg = self._resolution_config()
    accuracy_cfg = self._accuracy_config()
    diagnostics_cfg = self._diagnostics_config()

    # Compute port extension length
    extend_length = domain_cfg.extend_ports
    if extend_length == 0.0:
        extend_length = domain_cfg.margin_xy + domain_cfg.dpml

    # Extend waveguide ports into PML region
    original_bbox: list[float] | None = None
    if extend_length > 0:
        bbox = original_component.dbbox()
        original_bbox = [bbox.left, bbox.bottom, bbox.right, bbox.top]
        component = gf.components.extend_ports(
            original_component, length=extend_length
        )
    else:
        component = original_component

    # Build layer stack entries
    layer_stack_entries = []
    used_materials: set[str] = set()
    for layer_name, layer in stack.layers.items():
        layer_stack_entries.append(
            LayerStackEntry(
                layer_name=layer_name,
                gds_layer=list(layer.gds_layer),
                zmin=layer.zmin,
                zmax=layer.zmax,
                material=layer.material,
                sidewall_angle=layer.sidewall_angle,
            )
        )
        used_materials.add(layer.material)

    # Build dielectric entries
    dielectric_entries = []
    for diel in stack.dielectrics:
        dielectric_entries.append(
            {
                "name": diel["name"],
                "zmin": diel["zmin"],
                "zmax": diel["zmax"],
                "material": diel["material"],
            }
        )
        used_materials.add(diel["material"])

    # Extract port info from original component
    port_infos = extract_port_info(
        original_component, stack, source_port=source_cfg.port
    )

    # Resolve materials
    material_data = resolve_materials(
        used_materials, overrides=self._material_overrides()
    )

    # Compute source fwidth
    fwidth = source_cfg.compute_fwidth(wl_cfg.fcen, wl_cfg.df)
    source_for_config = source_cfg.model_copy(update={"fwidth": fwidth})

    # Translate domain.symmetries → SymmetryEntry for config
    symmetry_entries = [
        SymmetryEntry(direction=s.direction, phase=s.phase)
        for s in self.domain.symmetries
    ]
    if symmetry_entries:
        import warnings

        warnings.warn(
            "Symmetries are not yet used in production S-parameter runs "
            "(only applied in preview-only mode).",
            stacklevel=2,
        )

    # Build SimConfig
    sim_config = SimConfig(
        gds_filename="layout.gds",
        component_bbox=original_bbox,
        layer_stack=layer_stack_entries,
        dielectrics=dielectric_entries,
        ports=port_infos,
        materials=material_data,
        wavelength=wl_cfg,
        source=source_for_config,
        stopping=stopping_cfg,
        resolution=resolution_cfg,
        domain=domain_cfg,
        accuracy=accuracy_cfg,
        diagnostics=diagnostics_cfg,
        verbose_interval=diagnostics_cfg.verbose_interval,
        symmetries=symmetry_entries,
    )

    # Estimate MPI process count
    dpml = domain_cfg.dpml
    margin_xy = domain_cfg.margin_xy
    if original_bbox is not None:
        bbox_w = original_bbox[2] - original_bbox[0]
        bbox_h = original_bbox[3] - original_bbox[1]
    else:
        bbox = component.dbbox()
        bbox_w = bbox.right - bbox.left
        bbox_h = bbox.top - bbox.bottom
    # Use both layer and dielectric z-ranges for the cell height.
    # PDKs without explicit box/clad layers (e.g. cspdk) would otherwise
    # produce a cell that's too short, with PML touching the core.
    z_vals = [e.zmin for e in layer_stack_entries] + [
        e.zmax for e in layer_stack_entries
    ]
    for d in dielectric_entries:
        z_vals.extend((d["zmin"], d["zmax"]))
    z_min = min(z_vals)
    z_max = max(z_vals)
    cell_x = bbox_w + 2 * (margin_xy + dpml)
    cell_y = bbox_h + 2 * (margin_xy + dpml)
    cell_z = (z_max - z_min) + 2 * dpml

    _meep_np_est = estimate_meep_np(
        cell_x, cell_y, cell_z, resolution_cfg.pixels_per_um
    )
    # TODO: use _meep_np_est once Batch vCPU allocation is passed to the
    # container (lscpu reports host cores, not container vCPUs → OOM).
    meep_np = 2
    sim_config.meep_np = meep_np
    logger.info(
        "meep_np=%d (estimated %d, cell %.1f x %.1f x %.1f um, res %d)",
        meep_np,
        _meep_np_est,
        cell_x,
        cell_y,
        cell_z,
        resolution_cfg.pixels_per_um,
    )

    return BuildResult(
        config=sim_config,
        component=component,
        original_component=original_component,
    )

get_status

get_status() -> str

Get the current status of this sim's cloud job.

Returns:

Type Description
str

Status string ("created", "queued", "running",

str

"completed", "failed").

Raises:

Type Description
ValueError

If no job has been submitted yet.

Source code in src/gsim/meep/simulation.py
def get_status(self) -> str:
    """Get the current status of this sim's cloud job.

    Returns:
        Status string (``"created"``, ``"queued"``, ``"running"``,
        ``"completed"``, ``"failed"``).

    Raises:
        ValueError: If no job has been submitted yet.
    """
    from gsim import gcloud

    if self._job_id is None:
        raise ValueError("No job submitted yet")
    return gcloud.get_status(self._job_id)

plot_2d

plot_2d(**kwargs: Any) -> Any

Plot 2D cross-sections of the geometry.

Uses :meth:build_config so the plot shows exactly what meep processes — including extended ports and PML boundaries.

Accepts the same keyword arguments as :func:gsim.meep.viz.plot_2d.

Source code in src/gsim/meep/simulation.py
def plot_2d(self, **kwargs: Any) -> Any:
    """Plot 2D cross-sections of the geometry.

    Uses :meth:`build_config` so the plot shows exactly what meep
    processes — including extended ports and PML boundaries.

    Accepts the same keyword arguments as :func:`gsim.meep.viz.plot_2d`.
    """
    from gsim.meep.viz import plot_2d

    result = self.build_config()

    return plot_2d(
        component=result.component,
        stack=self.geometry.stack,
        domain_config=result.config.domain,
        source_port=result.config.source.port,
        extend_ports_length=0,
        port_data=result.config.ports,
        component_bbox=result.config.component_bbox,
        **kwargs,
    )

plot_3d

plot_3d(**kwargs: Any) -> Any

Plot 3D visualization of the geometry.

Uses :meth:build_config so the plot shows exactly what meep processes — including extended ports.

Accepts the same keyword arguments as :func:gsim.meep.viz.plot_3d.

Source code in src/gsim/meep/simulation.py
def plot_3d(self, **kwargs: Any) -> Any:
    """Plot 3D visualization of the geometry.

    Uses :meth:`build_config` so the plot shows exactly what meep
    processes — including extended ports.

    Accepts the same keyword arguments as :func:`gsim.meep.viz.plot_3d`.
    """
    from gsim.meep.viz import plot_3d

    result = self.build_config()

    return plot_3d(
        component=result.component,
        stack=self.geometry.stack,
        domain_config=result.config.domain,
        extend_ports_length=0,
        **kwargs,
    )

run

run(
    parent_dir: str | Path | None = None, *, verbose: bool = True, wait: bool = True
) -> Any

Run MEEP simulation on the cloud.

Parameters:

Name Type Description Default
parent_dir str | Path | None

Where to create the sim directory. Defaults to the current working directory.

None
verbose bool

Print progress info.

True
wait bool

If True (default), block until results are ready. If False, upload + start and return the job_id.

True

Returns:

Type Description
Any

SParameterResult when wait=True, or job_id string

Any

when wait=False.

Source code in src/gsim/meep/simulation.py
def run(
    self,
    parent_dir: str | Path | None = None,
    *,
    verbose: bool = True,
    wait: bool = True,
) -> Any:
    """Run MEEP simulation on the cloud.

    Args:
        parent_dir: Where to create the sim directory.
            Defaults to the current working directory.
        verbose: Print progress info.
        wait: If ``True`` (default), block until results are ready.
            If ``False``, upload + start and return the ``job_id``.

    Returns:
        ``SParameterResult`` when ``wait=True``, or ``job_id`` string
        when ``wait=False``.
    """
    self.upload(verbose=False)
    self.start(verbose=verbose)
    if not wait:
        return self._job_id
    return self.wait_for_results(verbose=verbose, parent_dir=parent_dir)

start

start(*, verbose: bool = True) -> None

Start cloud execution for this sim's uploaded job.

Raises:

Type Description
ValueError

If :meth:upload has not been called.

Source code in src/gsim/meep/simulation.py
def start(self, *, verbose: bool = True) -> None:
    """Start cloud execution for this sim's uploaded job.

    Raises:
        ValueError: If :meth:`upload` has not been called.
    """
    from gsim import gcloud

    if self._job_id is None:
        raise ValueError("Call upload() first")
    gcloud.start(self._job_id, verbose=verbose)

upload

upload(*, verbose: bool = True) -> str

Write config and upload to the cloud. Does NOT start execution.

Parameters:

Name Type Description Default
verbose bool

Print progress messages.

True

Returns:

Name Type Description
str

job_id string for use with :meth:start, :meth:get_status,

or str

func:gsim.wait_for_results.

Source code in src/gsim/meep/simulation.py
def upload(self, *, verbose: bool = True) -> str:
    """Write config and upload to the cloud. Does NOT start execution.

    Args:
        verbose: Print progress messages.

    Returns:
        ``job_id`` string for use with :meth:`start`, :meth:`get_status`,
        or :func:`gsim.wait_for_results`.
    """
    import tempfile

    from gsim import gcloud

    tmp = Path(tempfile.mkdtemp(prefix="meep_"))
    self.write_config(tmp)
    self._config_dir = tmp
    self._job_id = gcloud.upload(tmp, "meep", verbose=verbose)
    return self._job_id

validate_config

validate_config() -> Any

Validate the simulation configuration.

Returns:

Type Description
Any

ValidationResult with errors/warnings.

Source code in src/gsim/meep/simulation.py
def validate_config(self) -> Any:
    """Validate the simulation configuration.

    Returns:
        ValidationResult with errors/warnings.
    """
    from gsim.common import ValidationResult

    errors: list[str] = []
    warnings_list: list[str] = []

    if self.geometry.component is None:
        errors.append("No component set. Assign sim.geometry.component first.")

    if self.geometry.component is not None:
        ports = list(self.geometry.component.ports)
        if not ports:
            errors.append("Component has no ports.")
        elif self.source.port is not None:
            port_names = [p.name for p in ports]
            if self.source.port not in port_names:
                errors.append(
                    f"Source port '{self.source.port}' not found. "
                    f"Available: {port_names}"
                )

        # Validate monitor port names
        if ports and self.monitors:
            port_names = [p.name for p in ports]
            errors.extend(
                f"Monitor port '{m}' not found. Available: {port_names}"
                for m in self.monitors
                if m not in port_names
            )

    if self.geometry.stack is None:
        warnings_list.append(
            "No stack configured. Will use active PDK with defaults."
        )

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

wait_for_results

wait_for_results(*, verbose: bool = True, parent_dir: str | Path | None = None) -> Any

Wait for this sim's cloud job, download and parse results.

Parameters:

Name Type Description Default
verbose bool

Print progress messages.

True
parent_dir str | Path | None

Where to create the sim-data directory.

None

Returns:

Type Description
Any

Parsed result (typically SParameterResult).

Raises:

Type Description
ValueError

If no job has been submitted yet.

Source code in src/gsim/meep/simulation.py
def wait_for_results(
    self,
    *,
    verbose: bool = True,
    parent_dir: str | Path | None = None,
) -> Any:
    """Wait for this sim's cloud job, download and parse results.

    Args:
        verbose: Print progress messages.
        parent_dir: Where to create the sim-data directory.

    Returns:
        Parsed result (typically ``SParameterResult``).

    Raises:
        ValueError: If no job has been submitted yet.
    """
    from gsim import gcloud

    if self._job_id is None:
        raise ValueError("No job submitted yet")
    return gcloud.wait_for_results(
        self._job_id, verbose=verbose, parent_dir=parent_dir
    )

write_config

write_config(output_dir: str | Path) -> Path

Serialize simulation config to output directory.

Thin wrapper around :meth:build_config — writes GDS, JSON, and the runner script.

Parameters:

Name Type Description Default
output_dir str | Path

Directory to write layout.gds, sim_config.json, run_meep.py.

required

Returns:

Type Description
Path

Path to the output directory.

Raises:

Type Description
ValueError

If config is invalid.

Source code in src/gsim/meep/simulation.py
def write_config(self, output_dir: str | Path) -> Path:
    """Serialize simulation config to output directory.

    Thin wrapper around :meth:`build_config` — writes GDS, JSON, and
    the runner script.

    Args:
        output_dir: Directory to write layout.gds, sim_config.json, run_meep.py.

    Returns:
        Path to the output directory.

    Raises:
        ValueError: If config is invalid.
    """
    from gsim.meep.script import generate_meep_script

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    result = self.build_config()

    # Write extended component GDS
    result.component.write_gds(output_dir / "layout.gds")

    # Write JSON config
    result.config.to_json(output_dir / "sim_config.json")

    # Write runner script
    script_path = output_dir / "run_meep.py"
    script_content = generate_meep_script(config_filename="sim_config.json")
    script_path.write_text(script_content)

    logger.info("Config written to %s", output_dir)
    return output_dir

SourceConfig

Bases: BaseModel

Source excitation configuration.

Controls the Gaussian source bandwidth and which port is excited. When bandwidth is None (auto), compute_fwidth returns a bandwidth ~3x wider than the monitor frequency span (matching gplugins' dfcen=0.2 convention) so edge frequencies receive adequate spectral power.

Methods:

Name Description
compute_fwidth

Compute Gaussian source fwidth in frequency units.

compute_fwidth

compute_fwidth(fcen: float, monitor_df: float) -> float

Compute Gaussian source fwidth in frequency units.

When auto (bandwidth=None), returns max(3 * monitor_df, 0.2 * fcen) to ensure edge frequencies have enough spectral power.

Parameters:

Name Type Description Default
fcen float

Center frequency (1/um).

required
monitor_df float

Monitor frequency span (1/um).

required

Returns:

Type Description
float

Source fwidth in frequency units (1/um).

Source code in src/gsim/meep/models/config.py
def compute_fwidth(self, fcen: float, monitor_df: float) -> float:
    """Compute Gaussian source fwidth in frequency units.

    When auto (bandwidth=None), returns ``max(3 * monitor_df, 0.2 * fcen)``
    to ensure edge frequencies have enough spectral power.

    Args:
        fcen: Center frequency (1/um).
        monitor_df: Monitor frequency span (1/um).

    Returns:
        Source fwidth in frequency units (1/um).
    """
    if self.bandwidth is not None:
        # Convert wavelength bandwidth to frequency bandwidth
        wl_center = 1.0 / fcen
        wl_min = wl_center - self.bandwidth / 2
        wl_max = wl_center + self.bandwidth / 2
        return 1.0 / wl_min - 1.0 / wl_max
    return max(3 * monitor_df, 0.2 * fcen)

Symmetry

Bases: BaseModel

Mirror symmetry plane.

WavelengthConfig

Bases: BaseModel

Wavelength and frequency settings for MEEP FDTD simulation.

MEEP uses normalized units where c = 1 and lengths are in um. Frequency f = 1/wavelength (in 1/um).

Attributes:

Name Type Description
df float

Frequency width in MEEP units.

fcen float

Center frequency in MEEP units (1/um, since c=1).

df property

df: float

Frequency width in MEEP units.

fcen property

fcen: float

Center frequency in MEEP units (1/um, since c=1).


gsim.gcloud

Classes

RunResult dataclass

RunResult(sim_dir: Path, files: dict[str, Path] = dict(), job_name: str = '')

Result of a cloud simulation run.

Attributes:

Name Type Description
sim_dir Path

Root directory ({job_type}_{job_name}/).

files dict[str, Path]

Flat mapping of filename → Path inside output/.

job_name str

Cloud job identifier.

Functions

get_status

get_status(job_id: str) -> str

Get the current status of a cloud job.

Parameters:

Name Type Description Default
job_id str

Job identifier.

required

Returns:

Type Description
str

Status string — one of "created", "queued",

str

"running", "completed", "failed".

Source code in src/gsim/gcloud.py
def get_status(job_id: str) -> str:
    """Get the current status of a cloud job.

    Args:
        job_id: Job identifier.

    Returns:
        Status string — one of ``"created"``, ``"queued"``,
        ``"running"``, ``"completed"``, ``"failed"``.
    """
    job = sim.get_job(job_id)
    return job.status.value

print_job_summary

print_job_summary(job) -> None

Print a formatted summary of a simulation job.

Parameters:

Name Type Description Default
job

Job object from gdsfactoryplus

required
Source code in src/gsim/gcloud.py
def print_job_summary(job) -> None:
    """Print a formatted summary of a simulation job.

    Args:
        job: Job object from gdsfactoryplus
    """
    if job.started_at and job.finished_at:
        delta = job.finished_at - job.started_at
        minutes, seconds = divmod(int(delta.total_seconds()), 60)
        duration = f"{minutes}m {seconds}s"
    else:
        duration = "N/A"

    size_kb = job.output_size_bytes / 1024
    size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.2f} MB"
    files = list(job.download_urls.keys()) if job.download_urls else []

    print(f"{'Job:':<12} {job.job_name}")  # noqa: T201
    print(f"{'Status:':<12} {job.status.value} (exit {job.exit_code})")  # noqa: T201
    print(f"{'Duration:':<12} {duration}")  # noqa: T201
    mem_gb = job.requested_memory_mb // 1024
    print(f"{'Resources:':<12} {job.requested_cpu} CPU / {mem_gb} GB")  # noqa: T201
    print(f"{'Output:':<12} {size_str}")  # noqa: T201
    print(f"{'Files:':<12} {len(files)} files")  # noqa: T201
    for f in files:
        print(f"             - {f}")  # noqa: T201

register_result_parser

register_result_parser(solver: str, parser: Callable[[RunResult], Any]) -> None

Register a result parser for a solver type.

Parameters:

Name Type Description Default
solver str

Solver name (e.g. "meep", "palace").

required
parser Callable[[RunResult], Any]

Callable that takes a :class:RunResult and returns a solver-specific result object.

required
Source code in src/gsim/gcloud.py
def register_result_parser(solver: str, parser: Callable[[RunResult], Any]) -> None:
    """Register a result parser for a solver type.

    Args:
        solver: Solver name (e.g. ``"meep"``, ``"palace"``).
        parser: Callable that takes a :class:`RunResult` and returns
            a solver-specific result object.
    """
    _RESULT_PARSERS[solver] = parser

run_simulation

run_simulation(
    config_dir: str | Path,
    job_type: Literal["palace", "meep"] = "palace",
    verbose: bool = True,
    on_started: Callable | None = None,
    parent_dir: str | Path | None = None,
) -> RunResult

Run a simulation on GDSFactory+ cloud (blocking).

This function handles the complete workflow: 1. Uploads simulation files from config_dir 2. Starts the simulation job 3. Creates a structured directory sim-data-{job_name}/ with input/ (config files) and output/ (results) sub-dirs 4. Waits for completion 5. Downloads results into output/

Parameters:

Name Type Description Default
config_dir str | Path

Directory containing the simulation config files.

required
job_type Literal['palace', 'meep']

Type of simulation (default: "palace").

'palace'
verbose bool

Print progress messages (default True).

True
on_started Callable | None

Optional callback called with job object when simulation starts.

None
parent_dir str | Path | None

Where to create the sim directory. Defaults to the current working directory.

None

Returns:

Type Description
RunResult

RunResult with sim_dir, files dict, and job_name.

Raises:

Type Description
RuntimeError

If simulation fails

Example

result = gcloud.run_simulation("./sim", job_type="palace") Uploading simulation... done Job started: palace-abc123 Waiting for completion... done (2m 34s) Downloading results... done print(result.sim_dir) sim-data-palace-abc123/

Source code in src/gsim/gcloud.py
def run_simulation(
    config_dir: str | Path,
    job_type: Literal["palace", "meep"] = "palace",
    verbose: bool = True,
    on_started: Callable | None = None,
    parent_dir: str | Path | None = None,
) -> RunResult:
    """Run a simulation on GDSFactory+ cloud (blocking).

    This function handles the complete workflow:
    1. Uploads simulation files from *config_dir*
    2. Starts the simulation job
    3. Creates a structured directory ``sim-data-{job_name}/``
       with ``input/`` (config files) and ``output/`` (results) sub-dirs
    4. Waits for completion
    5. Downloads results into ``output/``

    Args:
        config_dir: Directory containing the simulation config files.
        job_type: Type of simulation (default: "palace").
        verbose: Print progress messages (default True).
        on_started: Optional callback called with job object when simulation starts.
        parent_dir: Where to create the sim directory.
            Defaults to the current working directory.

    Returns:
        RunResult with sim_dir, files dict, and job_name.

    Raises:
        RuntimeError: If simulation fails

    Example:
        >>> result = gcloud.run_simulation("./sim", job_type="palace")
        Uploading simulation... done
        Job started: palace-abc123
        Waiting for completion... done (2m 34s)
        Downloading results... done
        >>> print(result.sim_dir)
        sim-data-palace-abc123/
    """
    config_dir = Path(config_dir)

    if not config_dir.exists():
        raise FileNotFoundError(f"Config directory not found: {config_dir}")

    # Upload
    if verbose:
        print("Uploading simulation... ", end="", flush=True)  # noqa: T201

    pre_job = upload_simulation_dir(config_dir, job_type)

    if verbose:
        print("done")  # noqa: T201

    # Start
    job = sim.start_simulation(pre_job)

    if verbose:
        print(f"Job started: {job.job_name}")  # noqa: T201

    if on_started:
        on_started(job)

    # Create structured directory
    root = Path(parent_dir) if parent_dir else Path.cwd()
    sim_dir = root / f"sim-data-{job.job_name}"
    input_dir = sim_dir / "input"
    input_dir.mkdir(parents=True, exist_ok=True)

    # Move config files into input/
    for item in list(config_dir.iterdir()):
        shutil.move(str(item), str(input_dir / item.name))
    # Remove now-empty config_dir (may fail if it was CWD, etc.)
    shutil.rmtree(config_dir, ignore_errors=True)

    # Wait (suppress per-poll prints from gdsfactoryplus SDK)
    with contextlib.redirect_stdout(io.StringIO()):
        finished_job = sim.wait_for_simulation(job)
    if verbose:
        created = finished_job.created_at.strftime("%H:%M:%S")
        from datetime import datetime

        now = datetime.now(finished_job.created_at.tzinfo).strftime("%H:%M:%S")
        print(  # noqa: T201
            f"Created: {created} | Now: {now} | Status: {finished_job.status.value}"
        )

    # Check status
    if finished_job.exit_code != 0:
        _handle_failed_job(finished_job, sim_dir, verbose)

    # Download directly into sim_dir (SDK creates results/ subdirectory)
    raw_results = sim.download_results(finished_job, output_dir=sim_dir)
    files = _flatten_results(raw_results)

    if verbose and files:
        print(f"Downloaded {len(files)} files to {sim_dir}")  # noqa: T201

    return RunResult(sim_dir=sim_dir, files=files, job_name=job.job_name)

start

start(job_id: str, *, verbose: bool = True) -> str

Start cloud execution for a previously uploaded job.

Parameters:

Name Type Description Default
job_id str

Job identifier returned by :func:upload.

required
verbose bool

Print progress messages.

True

Returns:

Type Description
str

The job_name (human-readable label).

Source code in src/gsim/gcloud.py
def start(job_id: str, *, verbose: bool = True) -> str:
    """Start cloud execution for a previously uploaded job.

    Args:
        job_id: Job identifier returned by :func:`upload`.
        verbose: Print progress messages.

    Returns:
        The ``job_name`` (human-readable label).
    """
    from gdsfactoryplus.sim import PreJob

    pre_job = PreJob(job_id=job_id, job_name="")
    job = sim.start_simulation(pre_job)

    if verbose:
        print(f"Job started: {job.job_name}")  # noqa: T201

    return job.job_name

upload

upload(config_dir: str | Path, job_type: str, *, verbose: bool = True) -> str

Upload simulation files to the cloud. Does NOT start execution.

Parameters:

Name Type Description Default
config_dir str | Path

Directory containing simulation config files.

required
job_type str

Simulation type (e.g. "palace", "meep").

required
verbose bool

Print progress messages.

True

Returns:

Type Description
str

job_id string that can be passed to :func:start,

str

func:get_status, or :func:wait_for_results.

Source code in src/gsim/gcloud.py
def upload(
    config_dir: str | Path,
    job_type: str,
    *,
    verbose: bool = True,
) -> str:
    """Upload simulation files to the cloud. Does NOT start execution.

    Args:
        config_dir: Directory containing simulation config files.
        job_type: Simulation type (e.g. ``"palace"``, ``"meep"``).
        verbose: Print progress messages.

    Returns:
        ``job_id`` string that can be passed to :func:`start`,
        :func:`get_status`, or :func:`wait_for_results`.
    """
    config_dir = Path(config_dir)
    if not config_dir.exists():
        raise FileNotFoundError(f"Config directory not found: {config_dir}")

    if verbose:
        print("Uploading simulation... ", end="", flush=True)  # noqa: T201

    pre_job = upload_simulation_dir(config_dir, job_type)

    if verbose:
        print(f"done (job_id: {pre_job.job_id})")  # noqa: T201

    return pre_job.job_id

upload_simulation_dir

upload_simulation_dir(input_dir: str | Path, job_type: str)

Upload a simulation directory for cloud execution.

Parameters:

Name Type Description Default
input_dir str | Path

Directory containing simulation files

required
job_type str

Simulation type (e.g., "palace")

required

Returns:

Type Description

PreJob object from gdsfactoryplus

Source code in src/gsim/gcloud.py
def upload_simulation_dir(input_dir: str | Path, job_type: str):
    """Upload a simulation directory for cloud execution.

    Args:
        input_dir: Directory containing simulation files
        job_type: Simulation type (e.g., "palace")

    Returns:
        PreJob object from gdsfactoryplus
    """
    input_dir = Path(input_dir)
    job_definition = _get_job_definition(job_type)
    return sim.upload_simulation(path=input_dir, job_definition=job_definition)

wait_for_results

wait_for_results(
    *job_ids: str,
    verbose: bool = True,
    parent_dir: str | Path | None = None,
    poll_interval: float = 5.0,
) -> Any

Wait for one or more jobs to finish, then download and parse results.

Accepts job IDs as positional args or a single list/tuple::

wait_for_results(id1, id2)
wait_for_results([id1, id2])

For a single job, returns the parsed result directly. For multiple jobs, returns a list of results (same order as input).

Parameters:

Name Type Description Default
*job_ids str

One or more job ID strings, or a single list/tuple of IDs.

()
verbose bool

Print progress messages.

True
parent_dir str | Path | None

Where to create sim-data directories (default: cwd).

None
poll_interval float

Seconds between status polls (default 5.0).

5.0

Returns:

Type Description
Any

Parsed result (single job) or list of parsed results (multiple jobs).

Source code in src/gsim/gcloud.py
def wait_for_results(
    *job_ids: str,
    verbose: bool = True,
    parent_dir: str | Path | None = None,
    poll_interval: float = 5.0,
) -> Any:
    """Wait for one or more jobs to finish, then download and parse results.

    Accepts job IDs as positional args or a single list/tuple::

        wait_for_results(id1, id2)
        wait_for_results([id1, id2])

    For a single job, returns the parsed result directly.
    For multiple jobs, returns a list of results (same order as input).

    Args:
        *job_ids: One or more job ID strings, or a single list/tuple of IDs.
        verbose: Print progress messages.
        parent_dir: Where to create sim-data directories (default: cwd).
        poll_interval: Seconds between status polls (default 5.0).

    Returns:
        Parsed result (single job) or list of parsed results (multiple jobs).
    """
    # Support both varargs and a single list/tuple
    if len(job_ids) == 1 and isinstance(job_ids[0], (list, tuple)):
        job_ids = tuple(job_ids[0])

    if not job_ids:
        raise ValueError("At least one job_id is required")

    # Fetch initial job objects
    jobs: dict[str, Any] = {jid: sim.get_job(jid) for jid in job_ids}
    now = time.monotonic()
    start_times: dict[str, float] = dict.fromkeys(job_ids, now)
    end_times: dict[str, float] = {}
    terminal = {sim.SimStatus.COMPLETED, sim.SimStatus.FAILED}

    # Freeze timer for any jobs already finished
    for jid, job in jobs.items():
        if job.status in terminal:
            end_times[jid] = now

    # Track how many lines we printed last time (for overwriting multi-job)
    prev_lines = 0

    # Poll until all jobs reach a terminal state
    while not all(j.status in terminal for j in jobs.values()):
        if verbose:
            prev_lines = _print_status_table(
                jobs, start_times, prev_lines, end_times=end_times
            )
        time.sleep(poll_interval)
        for jid, job in jobs.items():
            if job.status not in terminal:
                jobs[jid] = sim.get_job(jid)
                # Freeze timer when job reaches terminal state
                if jobs[jid].status in terminal:
                    end_times[jid] = time.monotonic()

    # Final status display (with newline to finish the line)
    if verbose:
        _print_status_table(
            jobs, start_times, prev_lines, end_times=end_times, final=True
        )

    # Download + parse all
    results = []
    for jid in job_ids:
        job = jobs[jid]
        run_result = _download_job(job, parent_dir, verbose)
        results.append(_parse_result(job, run_result))

    return results[0] if len(job_ids) == 1 else results