Step-by-step tutorial · structdevice · digital P&R

Step-by-step: a custom device — from exemplar geometry to a PCell to P&R/LVS

This walks through the fit-device demo from the public gallery. The first four tutorials each drew one device; this one answers the next question: how does YOUR device enter a digital place-&-route flow? The chain is: a few exemplar drawings of the same device at different sizes → fit a parametric edge model → register a real PCell → place 173 instances from a device-level netlist → FlexDR detailed routing → live LVS match=True. It is also the living example of process purity: every device shape, layer number, wire width and spacing lives in this one example file — klink's mechanism layer ships zero process constants. Swapping in your own device means editing the numbers in this file, nothing else.

Prerequisites

  • Starter: it's already in the klink init <proj> scaffold (example_template/digital/fit_device_pnr_lvs.py); it needs a running KLayout session (it does live P&R+LVS).
  • KLayout running with the klink plugin loaded (point --port at your live session).
  • The whole flow is one command: python example_template/digital/fit_device_pnr_lvs.py --port <session-port> (add --draw-only to draw the devices and skip P&R/LVS; from a repo clone you can also run python -m examples_klink.public.demos.digital.fit_device_pnr_lvs --port <session-port>).
This tutorial splits the demo's single run into 6 observable stages, each really executed and screenshotted in a live KLayout. The device in the demo is synthetic (clean, obviously-not-real numbers), so the whole demo runs with zero confidential data; swap _device_boxes() for boxes harvested from your own hand-drawn cells and the flow doesn't change by a single line.

1Exemplar geometry: start from one device

Everything starts from "the device you already know how to draw". An exemplar is the same device drawn at several parameter points — each exemplar is a set of role-named boxes (role → layer + bbox). Here it's a synthetic back-gate-style device with 4 roles on 3 process layers: channel (103/0), source/drain (104/0, protruding past the channel in x), and a back-gate plate (101/0, covering the channel and extending left for the gate lead). Two parameters drive the geometry: w_um (channel width) and l_um (source–drain gap):

SX, OUTER, CY, PL, PR = 2.5, 11.0, 2.0, 10.0, 8.0   # OUTER = FIXED S/D outer edge

def _device_boxes(W, L):
    return {
        "channel":    ("103/0", [-W/2, -(CY + L/2), W/2, CY + L/2]),
        "source":     ("104/0", [-(W/2 + SX), -OUTER, W/2 + SX, -L/2]),
        "drain":      ("104/0", [-(W/2 + SX), L/2, W/2 + SX, OUTER]),
        "gate_plate": ("101/0", [-(W/2 + PL), -(CY + L/2), W/2 + PR, CY + L/2]),
    }

EXEMPLAR_SIZES = [(10, 4), (50, 4), (10, 8), (50, 8)]   # span both W and L

The 4 exemplar points deliberately span both parameter axes (W varies, L varies, both vary) — the fitter is a regression, so the exemplars must vary each parameter independently, or it refuses with an explicit error ("collinear parameters"). In a real project these boxes come from your existing layouts: read them out of a few hand-drawn device cells at different sizes with shape.query.

A row of four back-gate device exemplars, left to right W=10 L=4, W=50 L=4, W=10 L=8, W=50 L=8, with visibly different widths and source-drain gaps
Step 1 · The 4 exemplars (W=10/50 × L=4/8). Orange = back-gate plate (101/0), green = source/drain (104/0), dense center hatch = channel (103/0). W drives the horizontal dimensions, L drives the source–drain gap.

2Fitting: exemplars → parametric model

The fitter (klink.domains.structdevice.pcell_fitter) regresses every box edge (x1/y1/x2/y2) of every role against the parameters (least squares) and classifies each edge — linear (cleanly driven by the parameters), constant (doesn't move), or unexplained (the data doesn't justify a law; you decide: a new parameter, or pin it to a constant). The workflow is: analyze to screen, read decisions_needed, confirm the model, then fit_table:

from klink.domains.structdevice import pcell_fitter as fitter

exemplars = [{"params": {"w_um": W, "l_um": L},
              "roles": {r: {"layer": ly, "box_um": bx}
                        for r, (ly, bx) in _device_boxes(W, L).items()}}
             for W, L in EXEMPLAR_SIZES]
report = fitter.analyze(exemplars, ["w_um", "l_um"])
print(report.summary())
# parameters: ['w_um', 'l_um']
# edges: 14 linear, 2 constant, 0 unexplained

table = fitter.fit_table(
    report, style="default",
    sample_order=[{"w_um": W, "l_um": L} for W, L in EXEMPLAR_SIZES],
    param_units={"w_um": "um", "l_um": "um"})

This run's actual screening: of 16 edges, 14 linear, 2 constant, 0 unexplained. The 2 constant edges are exactly the device model's "S/D outer edge fixed at ±11 µm" convention — the fitter discovered it from the data. The product is a klink_fitted_device_pcell_v2 fit table: one linear law base + Σ coef[p]·param[p] per edge (in dbu), e.g. the channel's left edge:

"channel": {"layer": "103/0", "edges": {
    "x1": {"kind": "parametric", "base": 0,      "coef": {"w_um": -500, "l_um": 0}},
    ...
}},
"source":  {"layer": "104/0", "edges": {
    "y1": {"kind": "parametric", "base": -11000, "coef": {"w_um": 0,    "l_um": 0}},  # constant edge
    ...
}}
Zoomed view of the W=10 L=4 exemplar with blue double-headed arrows marking channel width w_um and source-drain gap l_um, and an orange box marking the source outer edge as a coef=0 constant edge
Step 2 · The fit result laid over the geometry: blue arrows = parameter-driven edges (channel width w_um, source–drain gap l_um), orange box = the source outer edge the fitter classified as constant (coef=0). With this table the device draws at any parameter values, not just the 4 exemplar points.

3Register a PCell, draw variants

Hand the fit table to pcell.register_fitted on the KLayout plugin side and you get a real PCell (library klink_structdevice): parameters in, geometry out. The table binds at registration, so a drawn instance carries only its geometry parameters. Draw 3 variants at different parameters to prove the parametrization is real:

c.call("pcell.register_fitted", {"name": "demobg", "fit_table": FIT})  # FIT = fit-table json path

items = [{"pcell": "demobg", "library": "klink_structdevice",
          "params": {"w_um": W, "l_um": L, "style": "default"},
          "position_um": [i * 180.0, 0.0]}
         for i, (W, L) in enumerate([(10, 8), (20, 8), (50, 3)])]
c.instance_insert_pcell_many("FIT_VARIANTS", items)

Note that only (10, 8) is an exemplar point: W=20 is an interpolation inside the sampled range, and L=3 lies outside the sampled range [4, 8] — an extrapolation. The linear model doesn't vouch for parameters beyond the sample range (process rules are unknown there), but the geometric law still applies, and the LVS below proves all three sizes work.

Three device variants drawn by the same PCell, labelled dev10_8 W=10 L=8, dev20_8 W=20 L=8, dev50_3 W=50 L=3, progressively wider with different source-drain gaps
Step 3 · The same demobg PCell at three parameter sets: dev10_8 (exemplar point), dev20_8 (interpolation), dev50_3 (extrapolation). The geometry scales correctly with the parameters — the parametrization was fitted, not hard-coded.
The device library is example data too. The DEVICES table used by P&R below maps each device key to {params, pcell, library, style, fit_table} — devices are N-ary (params is an arbitrary dict; no W/L assumption), and klink presets none of it. Even each device's terminals (G/S/D position, orientation, layer) are computed from the fit table and stored in the example's own device_geom.json.

4Place many instances from a netlist

Now scale the single device up to a circuit. The input is a device-level netlist (the demo bundles add4.devnet.json, a 4-bit adder: 62 gates, 173 device instances, 96 nets); the process stack is the example-owned PUBLIC_PROCESS (3 routing layers + 2 via pairs). Rows, columns and row pitch are derived from demand, not magic numbers:

from dataclasses import replace
from klink.routing.grid.process_profile import ProcessProfile
from klink.routing.grid.floorplan import derive_grid, derive_row_pitch
from klink.domains.structdevice import layout_engine as eng

PUBLIC_PROCESS = ProcessProfile(          # every number example-owned; copy + edit for YOUR process
    routing_layers=("101/0", "104/0", "106/0"),
    gate_layer="101/0", sd_layer="104/0", channel_layer="103/0",
    vias=(("101/0", "102/0", "104/0"), ("104/0", "105/0", "106/0")),
    layer_directions={"101/0": "V", "104/0": "H", "106/0": "V"},
    wire_width_um=5.0, wire_clear_um=2.0, prl_spacing_um=10.0, prl_length_um=15.0,
    via_pad_um=5.0, litho_tol_um=1.0, y_step_um=30.0, col_pitch_um=100.0, margin_um=60.0)

P = replace(PUBLIC_PROCESS, wire_clear_um=5.0, grid_pitch_um=10.0,
            col_pitch_um=100.0, y_step_um=35.0)     # density knobs, also example-owned
nl = json.loads(Path("examples_klink/public/demos/add4.devnet.json").read_text())
raw = eng.load_device_geom(GEOM)          # device geometry table (terms/pads/body), generated from the fit table
_, _, terms = eng._geom_tables(raw)
rows, cols = derive_grid(len(nl["groups"]))          # 62 gates -> 8 x 8
rp = derive_row_pitch(nl, rows, cols, terms, y_step=P.y_step_um,
                      width_um=P.wire_width_um, wire_clear_um=P.wire_clear_um,
                      via_pad_um=P.via_pad_um, n_horiz_layers=3)   # -> 170.0 um
placement = eng.place_grid(nl, rows, cols, profile=P, row_pitch=rp)   # 173 instances

derive_row_pitch sets row pitch = device stack height + routing channel, with the channel sized as peak crossing demand ÷ number of routing layers — more layers, narrower channel, smaller layout. The placement itself is one batch instance.insert_pcell_many call (see the batch-RPC rule).

An 8 by 8 array of devices, each column a stack of 2 to 3 fitted PCell devices, with routing channels left between rows and no wiring yet
Step 4 · 173 fitted-PCell instances placed as an 8×8 gate grid (one column per gate, devices stacked vertically, load on top). The blank bands between rows are the routing channels derive_row_pitch reserved; nothing is wired yet.

5Declare nets + detailed routing

The connectivity intent comes from the netlist: each net declares which device terminals belong to one electrical node. This declaration is both the router's input and the baseline LVS reconciles against in stage ⑥:

declared = [{"net": n["net_id"], "terminals": n["terminals"]} for n in nl["nets"]]
# e.g. {"net": "$abc$217$new_n15", "terminals": ["X1.S", "X1.G", "X2.D", "X102.G"]}

cut_layer = {tuple(sorted((lo, up))): P.cut_layer(lo, up) for (lo, _c, up) in P.vias}
ok, info, _ = eng.route_and_draw_flexdr(
    c, "DEMO_ADD4", nl, placement, profile=P, layers=list(P.routing_layers),
    vias=P.via_rules(), cut_layer=cut_layer, geom_path=GEOM,
    devices=DEVICES, use_rust=True)
# FlexDR 3.7s ok=True routed=94/94 markers=0

Of the 96 nets, VDD/GND go to the power grid (PDN: 104/0 rails + 106/0 straps, 166 PDN vias this run); the remaining 94 signal nets are routed by the FlexDR detailed router across the 3 layers (200 signal vias this run), with terminal access points and via rules all coming from the profile and the harvested device geometry. Only routed=94/94 with markers=0 (zero DRC violations) proceeds; a routing failure returns instructions (which net, why, which floorplan knob to change), not a bare error.

Overview of the fully routed 8x8 device array, with horizontal green traces in the row channels, vertical orange and gray traces crossing rows, and a power ring around the block
Step 5a · All 94/94 signal nets routed: green horizontal traces (104/0) run in the row channels, orange (101/0) and gray (106/0) verticals cross rows, and the PDN ring surrounds the block.
Zoom on two device columns: gate plates and source-drain pads are visible, plus traces on three routing layers and small crosshatched via stack squares
Step 5b · Detail (two columns of the first row): traces launch from the devices' pad access points; layer changes are via stacks (landing pads on both layers + the cut in between, the small squares).

6Live LVS: declared vs extracted

The last step is not "it looks connected" — KLayout's native connectivity extraction is the referee. lvs_check extracts the nets from the real drawn geometry (in-layer continuity + vias across layers), reconciles them one by one against the declared nets from stage ⑤, then runs a device-level netlist compare:

from klink.domains.structdevice.orchestrators import lvs_check
from klink.domains.structdevice.recipes import geom_terminal_provider

# device_terms = absolute terminal coordinates per instance (placement offset
# + terminal centers from `terms`; see the demo source for the one-line dict)
res = lvs_check(c, "DEMO_ADD4", declared=declared, mode="lvsdb",
                connectivity=P.connectivity_spec(),      # derived from the SAME profile
                terminal_provider=geom_terminal_provider(raw),
                placement=placement, device_terms=device_terms)
# LVS ok=True match=True devices=173

mode="lvsdb" also writes a geometry-linked native .lvsdb — open it in KLayout's Netlist Browser for two-way cross-probing between layout and netlist. Note the connectivity rules LVS uses (which layers conduct, which are vias) are derived by P.connectivity_spec() from the same profile — router and referee read one process declaration, but the referee is KLayout's native extractor, not the router grading itself.

Overview of the finished 4-bit adder layout with a GND label at the top left and a VDD label at the bottom left, the power ring enclosing the whole 8x8 array
Step 6 · Finished overview (view.zoom_fit): DEMO_ADD4 after LVS match=True. The outer PDN rails carry GND/VDD text labels — with no pads, the peripheral rails ARE the power ports.

Verify, don't just look

As the tutorials index says, screenshots are for humans. The complete output of this run against a live KLayout:

=== fitter screening ===
parameters: ['w_um', 'l_um']
edges: 14 linear, 2 constant, 0 unexplained
drawn DEMO_DEVICES (synthetic fitted device)
[public] FlexDR 3.7s ok=True routed=94/94 markers=0
[public] LVS ok=True match=True devices=173
RESULT: PASS (synthetic fitted device: fit -> P&R -> LVS match)

Four structural quality gates, all required: fitter screening 0 unexplained (every edge accounted for); routing routed=94/94 with markers=0 (everything routed, zero DRC violations); LVS match=True (the netlist extracted from the 173 drawn devices matches the declared one). If any gate fails, the demo exits non-zero — there is no "close enough counts as done".

KLayout's Netlist Database Browser with DEMO_ADD4.lvsdb open; on the Cross Reference tab the Nets and Devices under DEMO_ADD4 both show green (matched)
Step 6 · the human-facing side of match=True: the DEMO_ADD4.lvsdb written by mode="lvsdb", opened in KLayout's Netlist Database Browser, with every net and device cross-referenced and matched (green). This is the native extraction behind the LVS ok=True match=True line above — the numbers are the verdict; this picture is just what that same verdict looks like to a person.

Next steps

Make the flow yours: copy examples_klink/public/demos/fit_device_pnr_lvs.py and swap three things — _device_boxes() (boxes read via shape.query from your own hand-drawn device cells), the layer numbers / widths / spacings in PUBLIC_PROCESS, and the netlist. klink's mechanism layer needs no edits. If you're starting from zero, read the first batch's Hall bar tutorial first for the basic geometry-first loop. The next tutorial covers the probe-card padframe demo: the pad ring is fixed first and the circuit's P&R has to meet it.