Schematic Ports & Pins
A schematic exposes connectivity to the outside world through two complementary objects:
- Ports — individual terminals (single waveguide port, single electrical
contact). They get materialized as
KCell.portson the resulting cell. - Pins — named bundles of ports. They group one or more cell-level ports under a single logical terminal (e.g. a DC pad's two contacts, a multi-mode bus).
This tutorial covers both APIs end-to-end: how to expose ports at the schematic level, how to pin them together, how the schematic stores everything as Pydantic data, and how the YAML round-trip works.
For schematic basics (instances, placements, connections) see Schematic-Driven Design.
import kfactory as kf
PDK setup
A minimal PDK with a single straight cell. The cell exposes two waveguide ports
(o1, o2) and groups them under one cell-level pin called "dc".
class LAYER(kf.LayerInfos):
WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0)
L = LAYER()
pdk = kf.KCLayout("SCHEM_PORTS_PINS", infos=LAYER)
@pdk.cell
def straight(width: int = 500, length: int = 10_000) -> kf.KCell:
"""Straight waveguide. Both optical ports are also grouped under a `"dc"` pin."""
c = pdk.kcell()
c.shapes(L.WG).insert(kf.kdb.Box(0, -width // 2, length, width // 2))
p1 = c.create_port(
name="o1",
width=width,
trans=kf.kdb.Trans(rot=2, x=0, y=0),
layer_info=L.WG,
)
p2 = c.create_port(
name="o2",
width=width,
trans=kf.kdb.Trans(x=length, y=0),
layer_info=L.WG,
)
c.create_pin(name="dc", ports=[p1, p2], pin_type="DC", info={"role": "bus"})
return c
Part 1 — Ports on a schematic
A KCell exposes ports via c.create_port(...). A schematic exposes ports via
schematic.ports — a dict mapping name → one of three things:
| Stored as | Created with | Meaning |
|---|---|---|
PortRef / PortArrayRef |
schematic.add_port(name, port=inst.ports[...]) |
Forward an instance port up to the top level |
Port[T] |
schematic.create_port(name, cross_section, x, y, ...) |
A new placeable top-level port whose position is computed at build time |
Schematic ports are materialized as KCell.ports when the cell is built.
For PortRef, the underlying instance port is added to the top-level cell under
the schematic-port key name.
add_port — forward an instance port
Use add_port whenever you want a top-level port that simply mirrors an existing
instance port. The schematic stores a PortRef lazily; the cell port is created
when create_cell runs.
@pdk.schematic_cell
def forwarded_ports() -> kf.Schematic:
schematic = kf.Schematic(kcl=pdk)
s = schematic.create_inst(
name="s", component="straight", settings={"width": 500, "length": 10_000}
)
s.place(x=0, y=0)
# forward each instance port up to the top level. the schematic-port key name
# becomes the cell-port name on the resulting KCell — it does not have to match
# the instance's port name.
schematic.add_port(name="left", port=s.ports["o1"])
schematic.add_port(name="right", port=s.ports["o2"])
return schematic
cell = forwarded_ports()
print("cell ports:", [p.name for p in cell.ports])
cell ports: ['right', 'left']
Inside the schematic, the entries are PortRef objects pointing at the underlying
instance:
for name, port in cell.schematic.ports.items():
print(f" {name!r}: {port}")
'left': s['o1']
'right': s['o2']
create_port — placeable top-level port
Use create_port when the top-level port is not a copy of an instance port —
typically when you want a port at a fixed location, or whose position/orientation
is a function of an instance's geometry but not directly any port.
Both absolute coordinates and PortRefs are accepted for x, y, and
orientation, so you can pin the new port to an instance:
@pdk.schematic_cell
def fanout_port() -> kf.Schematic:
schematic = kf.Schematic(kcl=pdk)
s = schematic.create_inst(
name="s", component="straight", settings={"width": 500, "length": 10_000}
)
s.place(x=0, y=0)
# an extra "monitor" port placed 5 µm above the centre of the straight, facing up
schematic.create_port(
name="monitor",
cross_section="WG_500",
x=5_000,
y=5_000,
orientation=90,
)
schematic.add_port(name="left", port=s.ports["o1"])
schematic.add_port(name="right", port=s.ports["o2"])
return schematic
Note
create_port requires a registered cross-section. The example above assumes a
WG_500 cross-section was registered on the PDK; in this notebook we don't
instantiate fanout_port() because the PDK has no cross-section table — it's
here for API illustration only. See the PDK page for
setting up cross-sections.
Part 2 — Pins
A pin is a named bundle of ports. It carries a pin_type ("DC" by default) and
free-form info. At the schematic level there are two ways to define one:
| Stored as | Created with | Meaning |
|---|---|---|
Pin |
schematic.create_pin(name, ports=[...]) |
Group existing top-level schematic ports |
PinRef |
schematic.add_pin(name, pin=inst.pins["..."]) |
Forward an instance's pin to the top level |
Pins are structural only for now — they're not part of nets, connections, or routes. They sit alongside ports on the schematic and on the resulting cell.
create_pin — explicit grouping
Use this when the pin's member ports come from different instances or don't align
with any single instance pin. The constituent port names must already exist in
schematic.ports (typically via add_port).
@pdk.schematic_cell
def explicit_pin() -> kf.Schematic:
schematic = kf.Schematic(kcl=pdk)
s = schematic.create_inst(
name="s", component="straight", settings={"width": 500, "length": 10_000}
)
s.place(x=0, y=0)
schematic.add_port(name="left", port=s.ports["o1"])
schematic.add_port(name="right", port=s.ports["o2"])
schematic.create_pin(
name="bus", ports=["left", "right"], pin_type="RF", info={"freq": 5}
)
return schematic
cell = explicit_pin()
for pin in cell.pins:
print(
f"pin {pin.name!r}:"
f" ports={[p.name for p in pin.ports]}"
f" type={pin.pin_type!r}"
f" info={dict(pin.info)}"
)
pin 'bus': ports=['left', 'right'] type='RF' info={'freq': 5}
add_pin — forward an instance pin
Use add_pin when an instance already exposes a pin you want at the top level.
inst.pins["name"] produces a PinRef; pass it to add_pin.
Pre-condition: every constituent port of the instance pin must be exposed as a
top-level schematic port beforehand. The materialization step at create_cell
time looks up each port via that exposure — if any port is missing you'll get a
clear error.
@pdk.schematic_cell
def forwarded_pin() -> kf.Schematic:
schematic = kf.Schematic(kcl=pdk)
s = schematic.create_inst(
name="s", component="straight", settings={"width": 500, "length": 10_000}
)
s.place(x=0, y=0)
# both underlying ports of the instance's "dc" pin are needed at the top level
schematic.add_port(name="left", port=s.ports["o1"])
schematic.add_port(name="right", port=s.ports["o2"])
# forward the pin — pin_type and info are inherited from the instance pin
schematic.add_pin(name="dc", pin=s.pins["dc"])
return schematic
cell = forwarded_pin()
for pin in cell.pins:
print(f"pin {pin.name!r}: type={pin.pin_type!r} info={dict(pin.info)}")
pin 'dc': type='DC' info={'role': 'bus'}
Part 3 — YAML round-trip
Schematic ports and pins serialize alongside instances and placements. The format accepts two shorthands:
- Ports:
"<inst>,<port>"(forwarded) or a{x, y, ...}dict (placeable) - Pins:
"<inst>,<pin>"(forwarded) or a{ports: [...], pin_type, info}block
yaml_str = """
instances:
s:
component: straight
settings: {width: 500, length: 10000}
placements:
s: {x: 0, y: 0}
ports:
left: s,o1
right: s,o2
pins:
bus:
ports: [left, right]
pin_type: RF
fwd: s,dc
"""
from ruamel.yaml import YAML
yaml = YAML(typ="safe")
schematic = kf.Schematic.model_validate(yaml.load(yaml_str))
print("ports:", {n: type(p).__name__ for n, p in schematic.ports.items()})
print("pins:", {n: type(p).__name__ for n, p in schematic.pins.items()})
print("bus.ports:", schematic.pins["bus"].ports)
print("fwd:", schematic.pins["fwd"])
ports: {'left': 'PortRef', 'right': 'PortRef'}
pins: {'bus': 'Pin', 'fwd': 'PinRef'}
bus.ports: ['left', 'right']
fwd: instance='s' pin='dc'
Pitfalls
- Schematic-port key ≠ original port name. When you forward an instance port
with
add_port(name="left", port=s.ports["o1"]), the cell's top-level port is named"left", not"o1". The same naming applies to ports referenced from pins. - Forwarded pins need exposed underlying ports. When you call
add_pin(pin=inst.pins["x"]), every constituent port of the instance pin must already be a top-level schematic port. The materialization step atcreate_celltime raises a clear error otherwise. - Pins are top-level only. They don't take part in nets, connections, or
routes. Connect ports the usual way (
inst.connect,add_route); pins exist purely to group ports for tooling that consumes them. - Names are unique within their dict.
add_port/create_portshareschematic.ports;add_pin/create_pinshareschematic.pins. Each raises on duplicate names.
Summary
| Operation | API |
|---|---|
| Forward an instance port to the top level | schematic.add_port(name, port=inst.ports[...]) |
| Create a placeable top-level port | schematic.create_port(name, cross_section, x, y, orientation) |
| Reference an instance port | inst.ports["o1"] → PortRef |
| Reference an array instance port | inst.ports["o1", ia, ib] → PortArrayRef |
| Group existing top-level ports into a pin | schematic.create_pin(name, ports=[...], pin_type, info) |
| Forward an instance pin to the top level | schematic.add_pin(name, pin=inst.pins[...]) |
| Reference an instance pin | inst.pins["dc"] → PinRef |
| Inspect cell-level ports / pins | cell.ports, cell.pins |