Skip to content

Download notebook (.ipynb)

Netlist & Schematic I/O

This page dives into the Netlist data model — the representation that kfactory uses for connectivity — and shows how to:

  • Inspect the Netlist object (instances, nets, ports)
  • Serialize a schematic to JSON/YAML and reload it with kf.read_schematic
  • Compare netlists and sort them for stable equality checks
  • Dump a netlist directly to JSON via Netlist.to_json

For the schematic-first design workflow itself (placement, connect, LVS basics) see the Schematic-Driven Design page.

import json
import tempfile
from pathlib import Path

from kfnetlist import PortRef

import kfactory as kf

PDK setup

We reuse the same minimal PDK from the overview page — a straight waveguide and a 90 ° euler bend, both registered on a dedicated KCLayout.

class LAYER(kf.LayerInfos):
    WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0)
    WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0)


L = LAYER()
pdk = kf.KCLayout("SCHEM_NETLIST", infos=LAYER)


@pdk.cell
def straight(width: int, length: int) -> kf.KCell:
    """Straight waveguide segment.

    Args:
        width: Width in dbu.
        length: Length in dbu.
    """
    c = pdk.kcell()
    c.shapes(L.WG).insert(kf.kdb.Box(0, -width // 2, length, width // 2))
    c.create_port(
        name="o1",
        width=width,
        trans=kf.kdb.Trans(rot=2, mirrx=False, x=0, y=0),
        layer_info=L.WG,
    )
    c.create_port(
        name="o2",
        width=width,
        trans=kf.kdb.Trans(x=length, y=0),
        layer_info=L.WG,
    )
    return c


@pdk.cell
def bend90(width: int, radius: int) -> kf.KCell:
    """90° Euler bend.

    Args:
        width: Width in dbu.
        radius: Nominal bend radius in dbu.
    """
    return kf.factories.euler.bend_euler_factory(kcl=pdk)(
        width=pdk.to_um(width),
        radius=pdk.to_um(radius),
        layer=L.WG,
    )

Building a schematic for inspection

A simple L-shaped path: s1 → b1 → s2.

@pdk.schematic_cell
def l_path() -> kf.Schematic:
    schematic = kf.Schematic(kcl=pdk)

    s1 = schematic.create_inst("s1", "straight", {"width": 500, "length": 15_000})
    b1 = schematic.create_inst("b1", "bend90", {"width": 500, "radius": 10_000})
    s2 = schematic.create_inst("s2", "straight", {"width": 500, "length": 15_000})

    s1.place(x=0, y=0)
    b1.connect("o1", s1.ports["o2"])
    s2.connect("o1", b1.ports["o2"])

    return schematic


cell = l_path()
cell


png

The Netlist data model

KCell.netlist() returns a dict[str, Netlist] keyed by cell name. Each Netlist has three attributes:

Attribute Type Meaning
instances dict[str, NetlistInstance] Every sub-cell placed in this cell
nets list[Net] Each net is a list of PortRef / NetlistPort entries that are connected
ports list[NetlistPort] Top-level ports exposed by this cell

A PortRef identifies a port by instance name and port name: PortRef(instance="s1", port="o2"). A NetlistPort identifies a cell-level port by name.

netlists = cell.netlist()
nl = netlists[cell.name].normalize()

print(f"Instances ({len(nl.instances)}):")
for name, inst in nl.instances.items():
    print(f"  {name}: component={inst.component!r}  settings={inst.settings}")

print(f"\nNets ({len(nl.nets)}):")
for i, net in enumerate(nl.nets):
    parts = []
    for p in net:
        if isinstance(p, PortRef):
            parts.append(f"{p.instance}.{p.port}")
        else:
            parts.append(f"<cell-port:{p.name}>")
    print(f"  net[{i}]: {' — '.join(parts)}")

print(f"\nTop-level ports ({len(nl.ports)}): {[p.name for p in nl.ports]}")
Instances (3):
  b1: component='bend90'  settings={'radius': 10000, 'width': 500}
  s1: component='straight'  settings={'length': 15000, 'width': 500}
  s2: component='straight'  settings={'length': 15000, 'width': 500}

Nets (2):
  net[0]: b1.o1 — s1.o2
  net[1]: b1.o2 — s2.o1

Top-level ports (0): []

Sorting nets for stable comparison

Port ordering within a net and the order of nets across the netlist can vary between runs. Netlist.sort() normalises both, making equality checks reproducible.

nl_a = cell.netlist()[cell.name]
nl_b = cell.netlist()[cell.name]

# sort() modifies in-place and returns self
nl_a.sort()
nl_b.sort()

assert nl_a == nl_b
print("Sorted netlists are equal ✓")
Sorted netlists are equal ✓

Schematic serialization

A Schematic is a Pydantic model, so standard Pydantic serialization methods work directly.

JSON export

model = cell.schematic
raw_json = model.model_dump_json(indent=2)
data = json.loads(raw_json)

# Show the top-level keys present in the serialized schematic
print("Top-level keys:", list(data.keys()))

# Show the placements section
print("\nPlacements:")
for name, placement in data.get("placements", {}).items():
    print(f"  {name}: {placement}")
Top-level keys: ['name', 'instances', 'placements', 'nets', 'routes', 'ports', 'pins', 'constraints', 'unit', 'info']

Placements:
  s1: {'mirror': False, 'x': 0, 'dx': 0, 'y': 0, 'dy': 0, 'orientation': 0.0, 'anchor': None}


/home/runner/work/kfactory/kfactory/.venv/lib/python3.14/site-packages/pydantic/main.py:542: UserWarning: Pydantic serializer warnings:
  PydanticSerializationUnexpectedValue(Expected `RouteNet[Annotated[int, str]]` - serialized value may not be as expected [field_name='nets', input_value=Connection[TypeVar](net=(...tance='s1', port='o2'))), input_type=Connection[TypeVar]])
  PydanticSerializationUnexpectedValue(Expected `Connection[Annotated[int, str]]` - serialized value may not be as expected [field_name='nets', input_value=Connection[TypeVar](net=(...tance='s1', port='o2'))), input_type=Connection[TypeVar]])
  PydanticSerializationUnexpectedValue(Expected `VirtualConnection[Annotated[int, str]]` - serialized value may not be as expected [field_name='nets', input_value=Connection[TypeVar](net=(...tance='s1', port='o2'))), input_type=Connection[TypeVar]])
  PydanticSerializationUnexpectedValue(Expected `RouteNet[Annotated[int, str]]` - serialized value may not be as expected [field_name='nets', input_value=Connection[TypeVar](net=(...tance='s2', port='o1'))), input_type=Connection[TypeVar]])
  PydanticSerializationUnexpectedValue(Expected `Connection[Annotated[int, str]]` - serialized value may not be as expected [field_name='nets', input_value=Connection[TypeVar](net=(...tance='s2', port='o1'))), input_type=Connection[TypeVar]])
  PydanticSerializationUnexpectedValue(Expected `VirtualConnection[Annotated[int, str]]` - serialized value may not be as expected [field_name='nets', input_value=Connection[TypeVar](net=(...tance='s2', port='o1'))), input_type=Connection[TypeVar]])
  return self.__pydantic_serializer__.to_json(

YAML round-trip with read_schematic

Schematics are typically stored as YAML files for version control. kf.read_schematic loads them back into a Schematic (or DSchematic when unit="um").

Schematic is also a Pydantic model, so model_validate works directly with a dictionary from yaml.safe_load — or you can use the convenience read_schematic helper.

with tempfile.TemporaryDirectory() as tmpdir:
    yaml_path = Path(tmpdir) / "l_path.yaml"

    # Exclude the 'unit' field — it is fixed by the Schematic subclass and must not
    # be present in the serialized payload for read_schematic to accept it.
    yaml_path.write_text(model.model_dump_json(indent=2, exclude={"unit"}))

    # Read back. NOTE: a known upstream bug currently prevents the JSON
    # produced by `model_dump_json` from round-tripping through
    # `read_schematic` when nets are present (see kfactory issue tracker —
    # the `nets` validator expects `{"p1": ..., "p2": ...}` keys but
    # `model_dump_json` serialises them as nested arrays). The pattern is
    # shown for documentation; uncomment to test once the upstream fix lands.
    try:
        reloaded = kf.read_schematic(yaml_path, unit="dbu")
        print("Reloaded schematic instances:", list(reloaded.instances.keys()))
        print("Reloaded schematic placements:", list(reloaded.placements.keys()))
    except KeyError as exc:
        print(f"(known upstream round-trip bug — KeyError: {exc})")
(known upstream round-trip bug — KeyError: 'p1')

The reloaded schematic carries the same instance definitions and placements. Calling reloaded.create_cell(output_type=kf.KCell, factories={"straight": straight, "bend90": bend90}) would materialise an identical physical cell.

Netlist as JSON

Netlist.to_json() serialises a netlist directly to a JSON string. This is the canonical wire format for handing a netlist to external tools or storing it in a regression-test fixture.

Below we build a small layout with the smallest available crossing primitive — a cross cell — connected to a second crossing through a straight waveguide. We annotate each instance with a text label so that the rendered layout matches the names that appear in the JSON output.

@pdk.cell
def cross(width: int, length: int) -> kf.KCell:
    """Minimal 4-port waveguide crossing.

    Two perpendicular rectangles meeting at the origin.  Real PDK crossings
    use tapered arms and an enclosure (see the `crossing45.py` tutorial) but
    this minimal version is sufficient for demonstrating the netlist format.

    Args:
        width: Waveguide width in dbu.
        length: Arm length in dbu (also the cell footprint).
    """
    c = pdk.kcell()
    c.shapes(L.WG).insert(
        kf.kdb.Box(-length // 2, -width // 2, length // 2, width // 2)
    )
    c.shapes(L.WG).insert(
        kf.kdb.Box(-width // 2, -length // 2, width // 2, length // 2)
    )
    c.create_port(
        name="o1",
        width=width,
        trans=kf.kdb.Trans(rot=0, mirrx=False, x=length // 2, y=0),
        layer_info=L.WG,
    )
    c.create_port(
        name="o2",
        width=width,
        trans=kf.kdb.Trans(rot=1, mirrx=False, x=0, y=length // 2),
        layer_info=L.WG,
    )
    c.create_port(
        name="o3",
        width=width,
        trans=kf.kdb.Trans(rot=2, mirrx=False, x=-length // 2, y=0),
        layer_info=L.WG,
    )
    c.create_port(
        name="o4",
        width=width,
        trans=kf.kdb.Trans(rot=3, mirrx=False, x=0, y=-length // 2),
        layer_info=L.WG,
    )
    return c


@pdk.cell
def crossing_demo() -> kf.KCell:
    """Two crossings connected by a straight, with instance-name labels."""
    c = pdk.kcell()

    x1 = c.create_inst(cross(width=500, length=10_000))
    x1.name = "x1"

    s_inst = c.create_inst(straight(width=500, length=15_000))
    s_inst.name = "s1"
    s_inst.connect("o1", x1.ports["o1"])

    x2_inst = c.create_inst(cross(width=500, length=10_000))
    x2_inst.name = "x2"
    x2_inst.connect("o3", s_inst.ports["o2"])

    # Annotate each instance with its name so the rendered layout matches the
    # JSON output below.
    for inst in c.insts:
        center = inst.ibbox().center()
        c.shapes(c.kcl.layer(L.WGEX)).insert(
            kf.kdb.Text(inst.name, kf.kdb.Trans(center.x, center.y))
        )

    return c


crossing_cell = crossing_demo()
crossing_cell


png

Extract the netlist and dump it to JSON. Calling sort() first keeps the instance ordering stable so the JSON output is reproducible.

nl = crossing_cell.netlist()[crossing_cell.name]
nl.sort()

print(nl.to_json())
{"instances":{"s1":{"kcl":"SCHEM_NETLIST","component":"straight","settings":{"length":15000,"width":500}},"x1":{"kcl":"SCHEM_NETLIST","component":"cross","settings":{"length":10000,"width":500}},"x2":{"kcl":"SCHEM_NETLIST","component":"cross","settings":{"length":10000,"width":500}}},"nets":[[{"instance":"s1","port":"o1"},{"instance":"x1","port":"o1"}],[{"instance":"s1","port":"o2"},{"instance":"x2","port":"o3"}]],"ports":[]}

The JSON contains three top-level keys:

  • instances — each instance's component, kcl (the owning KCLayout name), and settings (the constructor kwargs).
  • nets — each net is a list of {"instance": ..., "port": ...} entries that are electrically tied together.
  • ports — the top-level cell-exposed ports (empty here because crossing_demo doesn't expose any ports).

Building a Netlist programmatically

You can also construct a Netlist directly without going through a schematic — useful for testing or for importing connectivity from an external source.

from kfactory import Netlist

manual_nl = Netlist()

# Add instance definitions
manual_nl.create_inst(
    "wg1", kcl="MY_PDK", component="straight", settings={"width": 500, "length": 10_000}
)
manual_nl.create_inst(
    "wg2", kcl="MY_PDK", component="straight", settings={"width": 500, "length": 10_000}
)

# Add a top-level port
p_in = manual_nl.create_port("in")

# Connect: cell-level "in" is tied to wg1.o1
manual_nl.create_net(p_in, PortRef(instance="wg1", port="o1"))

# Internal net: wg1.o2 connects to wg2.o1
manual_nl.create_net(
    PortRef(instance="wg1", port="o2"),
    PortRef(instance="wg2", port="o1"),
)

manual_nl.sort()

print("Instances:", list(manual_nl.instances.keys()))
print("Top-level ports:", [p.name for p in manual_nl.ports])
print("Nets:")
for i, net in enumerate(manual_nl.nets):
    parts = [
        f"{p.instance}.{p.port}" if isinstance(p, PortRef) else f"<{p.name}>"
        for p in net
    ]
    print(f"  net[{i}]: {parts}")
Instances: ['wg1', 'wg2']
Top-level ports: ['in']
Nets:
  net[0]: ['<in>', 'wg1.o1']
  net[1]: ['wg1.o2', 'wg2.o1']

Summary

Task API
Extract netlist from a cell cell.netlist()dict[str, Netlist]
Iterate nets for net in nl.nets: for p in net: ...
Sort for stable comparison nl.sort()
Export schematic to JSON schematic.model_dump_json()
Load schematic from YAML/JSON file kf.read_schematic(path, unit="dbu")
Dump a netlist to JSON nl.to_json()
Build a netlist without a schematic Netlist(); nl.create_inst(...); nl.create_net(...)

See Also

Topic Where
Schematic placement & connections Schematics: Overview
45° crossing with virtual cells Schematics: 45° Crossing
Creating a full PDK PDK: Creating a PDK