circuitRF Reference Guide

PDK Authoring

Building a kit: the OpenPDK layout, Python PCells, models, technology and artwork.

This chapter is about producing a kit. If you are consuming one, read PDK Integration instead.

Follow OpenPDK

circuitRF supports the OpenPDK standard — https://github.com/fossi-foundation/open-pdks.

It is what the importer expects to find, it keeps a kit portable between tools, and it means the structure of your kit is a published thing rather than a convention you have to explain to every user. Produce that structure.

Beyond the structure itself, circuitRF reads a kit entirely at run time. Nothing about your kit is compiled into the product: every device type, parameter name, pin count and node role is learned from what you supply. Anything circuitRF cannot derive, you declare in a run-time data file beside the kit — never in a list inside circuitRF.

The four things a part needs

A complete part is four artefacts, and it is worth being explicit that they are separate:

What it is Where it comes from
Schematic side A symbol with pins, and a published parameter interface A symbol description your kit ships, translated on import
Model The device equations A compiled model library, or Verilog-A, or an SDD written in circuitRF's own expression language
Technology Layers, stackup, DRC rules A .ctech, or the OpenPDK technology description
Artwork The layout A stored .clay for a fixed cell, or a PCell generator for a parameterised one

The parameter interface is the join between the first two and the fourth. A PCell reads the same parameter list its symbol displays — one list, not two. A symbol showing W and a generator reading a different W is a defect that only surfaces as wrong artwork.

PCell generation with Python

A generated cell is a function of its parameters and its technology, and nothing else. Write the function, declare its parameters, call run().

from circuitrf_pcell import Parameter, Pin, Rect, Result, generator, run

@generator("MLIN", [Parameter.length("W"), Parameter.length("L")])
def mlin(params, tech):
    w = params.length("W")          # already in database units
    length = params.length("L")
    layer = tech.signal_layer
    return Result(
        shapes=[Rect(layer, 0, -(w // 2), length, w // 2)],
        pins=[Pin("1", 0, 0, layer, w, 180.0),
              Pin("2", length, 0, layer, w, 0.0)],
    )

run()

The support package is tools/pcell-python/circuitrf_pcell. Python 3.9 or later, and no third-party packages — a cell author should need nothing but an interpreter to get started.

What you get

Shapes Rect RoundedRect Circle Polygon (with holes) Curve Path Via Label
Curved edges Edge.line() Edge.arc(bulge) Edge.cubic(…)
Pins Pin(name, x, y, layer, width, outward_deg)
Parameters params.length() .real() .integer() .flag() .text(), each with a default
Technology tech.signal_layer tech.ground_layer tech.layer_named(…) tech.stackup
Warnings Result(diagnostics=[…])
Refusing raise — circuitRF reports it, naming the cell

tech.signal_layer and tech.ground_layer are the resolved answer, worked out by circuitRF before the request was sent. Do not re-derive them from the stackup: a second implementation of that rule fails silently, putting geometry on a plausible but wrong layer. They can be None — a layout with no technology still generates geometry — so have a fallback.

diagnostics is not an error channel. It is for a generator that did produce geometry and has a caveat about it. To refuse outright, raise.

Six things worth knowing before writing a real cell

1. There are no metres. Every length arrives in database units, converted by circuitRF with its own single rounding rule before it was sent. The resolution is deliberately not on the wire, so a script cannot do its own conversion — there is nothing to do it with. That is what keeps one rounding rule across the process boundary instead of two.

2. Round your own arithmetic with dbu(). Not round(), which is banker's rounding in Python (round(0.5) is 0, round(2.5) is 2), and not int(), which truncates. Both disagree with circuitRF at exactly the midpoints where two adjacent shapes decide whether they abut. Passing a fractional float where a coordinate is expected is refused by name rather than silently rounded — being made to write dbu(w / 2) is the point.

3. A pin carries width and outward direction, and they are required arguments. A connection is an edge, not a point; a bend needs to know which way its arm faces. This is the field most easily omitted and the most expensive to add later, which is why the constructor will not let you.

4. Declare each parameter's dimension correctly. Parameter.length("W") is what tells circuitRF to convert W from metres. A length you forget to declare arrives unconverted and your geometry is off by nine orders of magnitude; a non-length you declare as one is silently scaled. This is the one declaration worth checking twice.

5. Give every parameter a default, and state a length default in METRES. A parameter with no declared default is not placed: your generator still draws, from its own accessor fallback, but the parameter is not on the instance — nothing in the Properties Inspector to edit, and any drag handle naming it is reported as naming a parameter the cell does not have. The artwork looks entirely correct throughout. A length value reaches you already converted to database units; a length default is a value circuitRF holds and converts like any other, so Parameter.length("W", 10e-6) is 10 µm and Parameter.length("W", 10_000) is ten kilometres.

6. A generator must be deterministic given its declared inputs. No clock, no ambient or global state, no randomness, no set-iteration order, no accumulation whose order varies between runs. Two users on different machines must get identical geometry — when they do not, what they see is a design that changed by itself, and the cache keyed on those inputs is quietly poisoned. Reading a file is fine provided its content is part of your cell's cache key — see dataFiles.

stdout is the wire

A stray print() lands in the middle of a frame and desynchronises the stream, which surfaces as circuitRF reporting a malformed reply nowhere near the print. Write to sys.stderr; circuitRF surfaces that.

A complete worked example: a microstrip line

The full, running version is tools/pcell-python/example/mlin.py in the repository. It is deliberately written from the contract, not transcribed from circuitRF's own built-in MLIN — the test that compares the two asserts byte-identical geometry, and that proves something only because they were arrived at independently.

Walking the four artefacts for this one part:

Schematic side. MLIN's symbol declares two pins and two parameters, W and L. Whatever your kit ships as a symbol description is translated on import.

Model. MLIN's electrical model is circuitRF's own microstrip line — it reads W, L and the substrate from the technology. A kit supplying its own device would point at a compiled model library instead; see PDK Integration.

Technology. The generator asks for tech.signal_layer and gets the resolved answer. The substrate underneath it comes from the same .ctech, which is also what the electrical model reads — so the artwork and the model cannot disagree about which stack they are on.

Artwork. The generator above. Three things in it are the contract rather than a choice:

w = params.length("W")           # DATABASE UNITS, already converted
length = params.length("L")
half = w // 2                    # integer halving — an odd width lands the same way in C# and Python

return Result(
    shapes=[Rect(layer, 0, -half, length, half)],
    pins=[
        Pin("1", 0,      0, layer, w, 180.0),   # pin 1 AT THE ORIGIN, facing out to the left
        Pin("2", length, 0, layer, w,   0.0),   # the line runs along +X
    ],
)

Checking your work

python3 tools/pcell-python/verify.py

Self-contained: no circuitRF, no .NET, no test framework. It drives the example generators as real subprocesses over the real wire.

Parameter handles

A parameter handle makes a piece of your generated artwork draggable, with the drag editing the parameter that produced it. Declaring one is a list argument on the same Result, in the same function, in the same file — there is no separate declaration file, no second language and no registration step:

@generator("MLIN", [Parameter.length("W"), Parameter.length("L")])
def mlin(params, tech):
    w = params.length("W")
    l = params.length("L")
    layer = tech.signal_layer
    return Result(
        shapes=[Rect(layer, 0, -(w // 2), l, w // 2)],
        pins=[Pin("1", 0, 0, layer, w, 180.0), Pin("2", l, 0, layer, w, 0.0)],
        handles=[
            Handle("L", anchor=(0, 0),     at=(l, 0),     axis=0),
            Handle("W", anchor=(l // 2, 0), at=(l // 2, w // 2), axis=90),
        ],
    )

A handle states four things: which parameter it drives, where the grip is in cell-local database units, which way it moves, and — optionally — a label and a legal range. That is all.

You never state how much the parameter changes per unit of travel

circuitRF measures it, by asking your generator: at the start of a drag it perturbs the parameter, regenerates in memory, and reads where the same handle moved to. That is why no units appear in the declaration above — and why the same cell written in C# and in Python reads identically.

Declaring no handles is a complete and correct choice. A generator that declares none behaves exactly as it always did, and no existing cell has to be revisited.

A second example: a spiral inductor

tools/pcell-python/example/spiral.py. Where MLIN is the smallest cell that is still real, this is the smallest cell that is still interesting: four parameters that interact, so it shows what a parameterised cell actually buys you.

@generator("SPIRAL", [Parameter.length("Width"), Parameter.length("Space"),
                      Parameter.length("Inner"), Parameter.integer("Turns")])
def spiral(params, tech):
    w     = params.length("Width", 10_000)
    s     = params.length("Space", 10_000)
    inner = params.length("Inner", 100_000)
    turns = params.integer("Turns", 3)

    if turns < 1:
        raise ValueError(f"a spiral needs at least one turn; got {turns}")

    layer = tech.signal_layer or FALLBACK_LAYER
    pitch = w + s
    half, half_inner = dbu(w / 2), dbu(inner / 2)

    shapes = []
    for t in range(turns):
        r      = half_inner + half + (turns - 1 - t) * pitch   # this turn's centre-line radius
        r_next = r - pitch
        shapes.append(Rect(layer, -r - half,  r - half, r + half,  r + half))   # top
        shapes.append(Rect(layer,  r - half, -r - half, r + half,  r + half))   # right
        shapes.append(Rect(layer, -r - half, -r - half, r + half, -r + half))   # bottom
        top = r_next + half if t < turns - 1 else half                          # left, stopped short
        shapes.append(Rect(layer, -r - half, -r + half, -r + half, top))
        if t < turns - 1:                                                       # step inward
            shapes.append(Rect(layer, -r - half, r_next - half, -r_next + half, r_next + half))
    ...

Three things in it are worth copying:

The inner terminal is left facing +Y rather than being routed out: reaching it needs an air bridge or an underpass on another layer, which belongs to whatever places the cell, not to the cell.

Declaring your generators to circuitRF

A pcell-generators.json beside the kit — run-time data, never a list inside circuitRF:

{
  "schemaVersion": 1,
  "entry": "pcells/main.py",
  "pythonPath": ["lib"],
  "sources": ["pcells", "lib"],
  "dataFiles": ["tables/pads.csv"],
  "interpreter": null
}
Key What it is for
entry The script that calls run(). Relative to this file's own folder, so the kit can be moved or copied whole and still resolve.
pythonPath Added to PYTHONPATH, so your kit's own modules import with nothing to configure.
sources What your generators are built from — the cache key that decides whether an already-generated cell can be reused. Omit it and the entry script's own directory is used, which is the ordinary layout. Not pythonPath: that may point at a shared environment you do not own.
dataFiles Files your geometry depends on but which are not source — a table of pad sizes, a device list. Reading a file is fine provided its content is part of your cell's cache key, and declaring it here is how it becomes part of it. Undeclared, editing it changes nothing: circuitRF keeps handing out the cells built before you changed it.
interpreter Usually omitted; circuitRF finds one. Set it when your cells need packages that live in a particular environment. circuitRF does not bundle an interpreter and does not install packages on your behalf — a kit that needs an environment declares it.

It deliberately does not list the generators the kit offers. The describe call is the only source of that, and a second one would be a cache that can silently disagree with the script.

Giving a generated cell a schematic side

A kit may ship artwork and nothing else, and until it has a symbol that is exactly what it is: the cell can be placed in a layout and nowhere else. Dropping its palette tile on a schematic lands a blank box with no pins, and Update Schematic from Layout places nothing at all — there is no part for it to place.

Ship a .csym named after the generator and the cell gets a schematic side:

pcell-kit/
  pcell-generators.json
  pcells/main.py
  KIT_SPIRAL.csym          <- the symbol for the KIT_SPIRAL generator
  symbols/KIT_MLIN.csym    <- or in a symbols/ folder, for a kit with many cells

.csym is circuitRF's own symbol format, so you draw the symbol in circuitRF's symbol editor — File ▸ New ▸ Symbol, draw it, save it under the generator's name. There is no description language to learn, and nothing to declare: the file's name is the declaration, for the same reason pcell-generators.json lists no generators. A name that matches no generator is simply a file nothing reads.

Three consequences worth knowing:

The symbol buys the schematic side, not a model: a cell with artwork and a glyph still has no device equations, and a run says so. Supply those the way The four things a part needs describes.

Traps worth a callout

A parameter declared as text silently ignores a number

Kit PCell parameters are commonly declared as strings. Supply a numeric value where a string is expected and it can be ignored — the generator falls back to its default. There is no error message. The symptom is wrong artwork, and it will look entirely plausible.

Tell your users to check the produced geometry, not the messages. And when you author a parameter, ask whether it really needs to be text: a number declared as a number cannot fail this way.

A unit belongs in the row's unit field, not in the value

circuitRF's expression parser has no unit-suffix production, so a value written as 60u in an expression is a parse error — and an unresolvable value is skipped rather than reported at the point you typed it. Some kits' own scripting accepts that spelling, which means the difference does not show up until Run. See Units.

Non-determinism poisons the cache silently. It is worth repeating because it is the failure with no symptom: generated cells are cached on (cell, parameter values, technology), so a generator that consults anything outside those inputs produces stale or inconsistent geometry with no error anywhere.

Do not flatten your own curves. Emit Edge.arc(bulge) and Edge.cubic(…) and let circuitRF flatten at screen resolution. Flattening is a rendering decision, and a generator that pre-flattens has made it once, at the wrong time, for every zoom level.

See also: PCells · PDK Integration · Units · Technology · Compiled Verilog-A models · The SDD.