Agent layout guide
- Layout with cicpy, a field guide
- The sidecar flow — the current best method
- The loop
- Read the netlist before grouping
- Placement API, in the order you use it
- Spacing facts, sky130
- Geometry model
- Routing
- Look before you route
- Aim at a channel, never at a coordinate
- Ask before you draw
- Router facts that cost a day to learn
- Verification beyond DRC
- Worked example
Layout with cicpy, a field guide
This page is the operational guide for doing schematic driven layout with cicpy. It is written for agents, but everything in it applies to humans. The API reference lives in pycell, layout and routes, this page is about how to work: the loop, the conventions, and the facts about the libraries and the design rules that are expensive to rediscover.
The sidecar flow — the current best method
Everything below this section still works and is still the reference
for the primitives, but since 2026-08 the way to lay out a large
analog cell is hierarchical and declarative: one python sidecar
beside the design holds the whole truth — one class per cell, one
nested class per subcell, all in <CELL>.py. LELOTEMP_OTAR in
lelo_temp_sky130a is the worked example — eight subcells and the
top, all DRC clean and LVS “Circuits match uniquely”, from one
LELOTEMP_OTAR.py.
The classes are REAL: Stack subclasses the core StackGroup, and
the recipe builds the declared class itself, so a hook’s self is
the group that was actually placed — self.addConnectivityRoute is
group-scoped, self.layout is the parent, and a rename in
cellgroup.py breaks the design file loudly instead of silently.
SidecarCell subclasses both the recipes AND LayoutCell, so the
class IS the cell being built and is handed to itself as the pycell
— every hook it declares runs, in both passes. A cell that needs
more than declarations overrides
beforePlace/afterPlace/beforeRoute/afterPaint/place/route and
calls super(); ask self.assembled when the override is only
right in one of the two passes.
One class, one cell
design/<LIB>/<CELL>.py:
from cicpy.sidecar import SidecarCell, Stack, Mirror
class LELOTEMP_OTAR(SidecarCell):
place = {"groupbreak": 6, "channel": 6} # flat-build knobs
class p_bias(Stack): # class name = subcell name;
match = r'^(xba\d+|xstack_p_bias_(top|bot)|xfill_p_bias_\d+)$'
group = "pmos" # base = Stack|DiffPair|Mirror
channel = "bias" # named vertical channel
order = ['xba1', 'xba8', 'xba2', 'xba6', 'xba7', 'xba3']
def beforeRoute(self, entry): # self IS the built group
self.layout.addConnectivityRoute(...) # parent-scoped
self.addConnectivityRoute(...) # group-scoped
return None # True = fully routed here
class r_deg(Stack):
match = r'^(xd2<\d+>|...)$'
fill = False # no dummy fill for resistors
rows = [ # the floorplan, bottom row
[n_load_a, n_load_b, n_mirr, r_deg], # first; the classes
[p_in_a, p_in_b, p_bias, p_sw], # themselves, so a
] # typo is a NameError
supplies = [ # rings + strap connections
{"net": "VDD_1V8", "ring": "t", "strap": "top",
"guard_exclude": "^xbs6$"},
{"net": "VSS", "ring": "b", "strap": "bottom",
"strap_exclude": "^xd2<[1-9]"},
]
#- the assembled top IS the cell, so its declarations sit on the
#- cell class: `channel` um between the rows, one ChannelRoute
#- per crossing net; presence of `routes` enables the hier build
channel = 8
routes = [
{"net": "VCP", "track": 6, "drops": [[n_mirr, "M2", "left"],
[p_bias, "M2", "right"]]},
{"net": "VS", "track": 14, "layer": "M4",
"drops": [[r_deg, "M4", "left"]]},
]
SidecarCell.compile() turns the class into the spec dict; the two
recipes that execute it live in core/sidecarcell.py and are mixed
into every sidecar cell – SidecarPycell for the flat build,
HierLayoutCell whose place()/route() assemble the published
subcells natively; publication in core/subcell.py. Which one runs
is the cell’s role, and cic.py takes that from the name it is
building: <CELL> flat, the scaffold <CELL>_HIER the assembly.
There is no separate assembly class — a pass that builds a cell the
design does not own is a pass whose hooks the design cannot reach,
which is exactly what hier_cell used to be. Detection is by content: a
<CELL>.py defining a SidecarCell subclass is the sidecar; a
module with module-level hooks and data is a classic pycell,
unchanged — the escape hatch for a cell the recipe cannot say.
Subcell hooks are methods — beforePlace(self, entry) /
beforeRoute(self, entry), run between afterPlace and beforeRoute.
There is no class-level route hook: LayoutCell.route() is a real
method a hook would shadow; claim the subcell by returning True from
beforeRoute. A separate <SUBCELLNAME>.py beside the design still
works (plain functions (layout, entry), legacy route included)
when a subcell’s routing outgrows the sidecar file, but the class
hooks win when both exist, and stubs are no longer generated. A
DiffPair/Mirror declines the built-in series router through its
routeInternal() — implementing that method on the class is where a
real diffpair/mirror router will land.
Wires: the router’s conclusions live in the sidecar
The maze router decides; the decision belongs in the design. A subcell class declares its stack-level routes as
class p_bias(Stack):
...
wires = [
("VO", "M1", "||", "trunkx=304100"),
("VBP", "blocked", "path is not a shape route.py can draw"),
]
wires_key = "6485b44f0f02"
each 4-tuple ordinary addConnectivityRoute arguments – edit them
like any other route – and a ("net", "blocked", reason) triple a
net the search proved unroutable, replayed as blocked rather than
quietly retried. Declared nets REPLAY: no track map, no A* search,
which are the whole cost of the flat build (measured: 74 s to 0.5 s
on LELOTEMP_BIAS_IBP, 15 s to 0.5 s on LELOTEMP_OTAR, outputs byte
identical). route.py still redraws under whatever the technology
says today.
The options are RESOLVED – a trunkx is a coordinate – so
wires_key fingerprints the stack’s own instances, and any
placement change makes the block stale: the router says so, ignores
it, searches afresh, and writes every searched stack’s conclusions
to <CELL>.routes.py beside the design as a paste-ready block. The
loop is: build once, read <CELL>.routes.py, paste the blocks into
the sidecar, build again. Undeclared nets always search, so a wires
block may cover a stack partially. CICPY_NO_ROUTEPLAN=1 ignores
every declaration.
The flow
cd work
make subcells CELL=X # flat build; publishes each subcell's
# .mag/.cic/.sch/.sym and X_HIER.spice
make hier CELL=X # assembles the top from the published cells
# and writes X.mag (spi2mag --outcell X)
make drc CELL=X_P_BIAS # every subcell verifies standalone
make gds cdl lvs CELL=X_P_BIAS # gds FIRST or extraction is stale
make drc CELL=X
make gds cdl lvs CELL=X
Read the LVS verdict from the Final result: line and nowhere
else: netgen prints “Netlists match uniquely with port errors” on
failing runs, so grepping for “match uniquely” green-lights broken
cells. Measured — a subcell shipped with its ladder unrouted behind
exactly that false positive.
ChannelRoutes and drops
Each hier: routes: entry lays one full-width bar (a ChannelRoute,
default M3) on a named channel track and connects pins to it with
addRouteConnection drops. Drops are discovered: every placed
subcell whose ports expose the net gets one, using the route-level
defaults (layer: M2, align: center, cuts: 2, pin cut on). The
drops: list only overrides — [inst, layer, align, 'nopin'] or
the dict form {inst:, layer:, align:, cuts:, pin_cut:} — for the
columns where pins share an x and must split by layer or alignment.
After the drops, the bar is trimmed to its outermost connection and
the port refreshed.
Via and cut behaviour worth knowing (all enforced in cicpy, not in the design):
- A lone 1x1 via is the last resort everywhere. Cut selection walks 2x1 → 1x2 → 1x1 and takes the first that fits the target; the maze router’s via emitter does the same, space-checked at the candidate’s own extent.
- The pin cut follows the align: flush left on
align: left, flush right onright, centered and clamped inside the pin otherwise. A centered two-cut pad on an aligned drop otherwise overhangs the pin into the neighbouring lane (li.3, measured). - The rail cut avoids the trunk traffic: it slides along the channel bar away from other nets’ drop verticals, within the window where its pad still covers the wire.
Dummies are supply devices
A fill transistor shorts to its stack’s supply – PMOS dummies to VDD, NMOS dummies to VSS – in three places that must agree:
- the hand schematic carries one fill instance per device class
with every pin on the supply (lowercase
xfill_*names); - the generated subcell netlists emit each fill with all terminals on the stack’s supply;
- the layout straps the fill’s D/G/S and ties the strap into the adjacent tap row.
The floorplan consequence: fills go at the bottom of a column, below every pin span. A supply-tied bar inside a rail’s span blocks the lane – measured, a drain net degraded to an M2 rail whose via pads then blocked the gate-tab lane.
Trunks come from pins, never from coordinates
trunkx is the resolved form the tools emit; a design never writes
it. Stack pycells state their rails with the pin-relative options:
trunkright– the pins’ common overlap, right edge: the rightmost trunk that still lies on every pin (a short bar narrows it for everyone, which is the point);trunkleft– the same from the left;trunktab– centred on the rightmost narrow (<=4 um) rect, the gate-tab lane; rightmost because duplicate subports plant false tabs to the left.
They resolve against the route’s collected rects at draw time, so the same pycell survives a resize untouched – verified when the OTA went from 6 to 4 input devices and every hand rail followed.
Conventions that are load-bearing
- Schematic instance names are lowercase. The netlist keeps the
name verbatim;
name=Xxfill_...reached the tools asxxfill_..., slipped past everyxfill_check, and published two phantom subcells at the origin on top of a device row. Thexfill_prefix is reserved for fill devices (layout-generated dummies and their schematic LVS counterparts). - Stack order is placement: the
order:list is bottom-to-top, and it is where tab-lane conflicts are solved. An N stack puts its gate device (xns*) at the BOTTOM so the tab-lane rail spans the rows above it; interleaved gate tabs in one 3.2 um lane are unroutable at any layer pair. - Published subcells keep parent-absolute coordinates; the
assembly cancels them per instance (
xcell = -sub.x1). The publish frame shifts whenever flat content changes, which makes every .mag diff 100% churn — diff geometry normalized by the label shift, not line by line. - Design pycells import publication helpers from
cicpy.core.subcell(a compat forward frommazerouterexists, because a failed pycell import is swallowed and the stack silently publishes without its routes).
The loop
Layout is an iteration, not a single generation. Every change goes through the same cycle, and the two verification steps are not optional:
cd work
cicpy sch2mag <LIB> <CELL> # generate placement from schematic
make drc CELL=<CELL> # let the design rules judge it
cicpy svg ../design/<LIB>/<CELL>.cic <tech> <CELL> --I <libs...>
# render it and LOOK at it
Routing has a stricter loop: one route, one check. Run
cicpy sch2mag --strict <LIB> <CELL> and the flow checks connectivity
after every route, stopping at the first one that creates a short, with
the command and file:line in the error. It also refuses to route at all
while the placement itself is shorted. The connectivity MCP tool runs
the same check on demand and lists every short and open with route
attribution.
Rules that follow from the loop:
- Never guess spacing. Design rules are not monotonic in distance: a gap that is too small for one rule can be too large for another, and the clean values are found empirically. Change one spacing, rerun DRC, read the rule names it prints.
- Look at the picture. DRC counts do not show a stack placed in the wrong row, a floating strap, or a hole in a guard ring. The SVG does.
- Do not route before placement is DRC clean. Routing on top of a dirty placement mixes two error sources.
Reading the SVG
cicpy svg writes <CELL>_svg/<CELL>.svg. Two things to know:
- The SVG y axis points down, layout y points up. The pmos row you placed on top renders at the bottom of the image. When identifying a stack in the picture, confirm against coordinates from the mag file before acting on it.
- Convert to a raster to inspect
(
rsvg-convert -h 1200 -b white cell.svg -o cell.png), and crop the region you care about rather than squinting at the whole cell.
Locating DRC errors
make drc prints rule names and a count. To get coordinates, step through
the errors in magic:
load ../design/<LIB>/<CELL>.mag
set b [view bbox]
box values [lindex $b 0] [lindex $b 1] [lindex $b 2] [lindex $b 3]
expand
drc style drc(full)
drc catchup
drc find
puts "ERRORBOX [box values]"
drc why
The reported coordinates are in magic internal units, which are the mag file units times two.
Read the netlist before grouping
Placement groups come from schematic instance names, but the grouping
must follow the circuit, not the names as they happen to be. Netlist
the cell and read the connectivity: which devices share a source node,
which are the matched pair, which are powerdown pulls. Name collisions
(a bussed xa1[3:0] next to a scalar xa1) and devices that ended up in
the wrong group are common in schematics that have been edited a lot.
Rename instances in the schematic until every group is one function.
Conventions:
- The group name of an instance is the leading letters of its name:
^(x\D+)—xbl0<2>andxbl5are both groupxbl, andxd2is groupxd, notxd2. - A group holds devices of one width only. Stacks place devices on a shared column, mixed widths do not stack.
- Give matched devices one group and split them in the layout script, do not scatter them over groups.
Placement API, in the order you use it
def afterPlace(layout):
grp = layout.makeCellGroup("pmos")
a = grp.addStackByGroup("xbl", name="p_in") # whole group
b = grp.addStack("p_in_b", instances, preserveOrder=True) # explicit slice
for s in (a, b):
s.stack() # REQUIRED after slicing: packs the column,
# split stacks inherit interleaved positions
# from their source group and have holes
grp.fillDummyTransistors() # fill short columns to the tallest,
# with dummies of each column's own device
a.addTaps() # tap cells above and below the column
b.mirror() # mirror a matched half about its axis
b.abutRight(a) # A.abutRight(B): A moves to the right of
# B, bottoms aligned. abutTop: A above B,
# lefts aligned.
grp.updateBoundingRect() # after moving stacks, before group abuts
grp2.abutTop(grp, space=...)
grp.routeDummyDevices() # M1 strap across each filler
layout._route_scopes = {"p_in": a, ...} # hand stacks to beforeRoute
Facts that are not obvious from the signatures:
addStackByGroupdoes not compact. The first pass drops devices on the routing grid and a stack keeps those positions; callstack()when you build stacks from slices.addTapsderives the tap cell name from the device name (..._12C5F0becomes..._12CTAPBOT/TOP). Device variants like LVT have no tap counterpart, the plain tap of the same width class is used automatically. If no tap exists at all the stack warns and stays untapped — treat that warning as an error.mirror()is for matched pairs: mirror the right half, then abut it against the left half. The seam then has mirror symmetric edge geometry, which is allowed to abut where two identical columns are not.- Dummies are created by
fillDummyTransistorsasxfill_<stack>_<n>physical instances of the column’s base device. They are not in the netlist and get an M1 strap fromrouteDummyDevices. - Group
abutTop/abutRightmoves the whole group. Its bounding box comes from its stacks, so anything you forgot to put in a group does not move with it and anything misplaced inside stretches the box.
Spacing facts, sky130
Found empirically, verified by DRC. Units below are cicpy layout units where 200 units = 1 um.
| boundary | spacing | why |
|---|---|---|
| pmos stack to pmos stack | abut, 0 | the n-wells must merge. Any small gap puts two well edges inside the 1.27 um nwell spacing (nwell.2a); the well reaches ~1.5 um beyond the stack box, so “a bit of margin” is the worst choice |
| nmos stack to nmos stack | 2 um (400) | abutting violates licon spacing across the seam and magic refuses subcell abutment on locali; gaps of 0.3 to 1.5 um each trip a different tap or diffusion rule. 2 um is the smallest clean value found |
| mirrored matched halves | abut, 0 | mirror symmetric seam geometry, works for both nmos and pmos pairs |
| poly resistor to anything | 2 um (400) | poly.9 wants 0.48 um from resistor poly to any diffusion or poly, and the resistor guard adds its own tap rules |
| pmos row to nmos row | 2 um (400) | well edge to nmos diffusion |
Geometry model
- A cell’s FIXED_BBOX is the abutment box, deliberately smaller than the drawn content. Stacked devices overlap: content overhangs the box and neighbouring rows share their boundary geometry. Placement, pitches and abutment all work on this box. Do not “fix” a cell whose drawing is bigger than its box, that is the design.
- The vertical pitch inside a stack equals the box height (800 units, 4 um for the standard cells). The visible gap between the drawn devices is the horizontal routing channel, not wasted space — finished cells fill it with M2/M3 routes.
- Everything in a
.cicfile is in database units (100 units = 5 nm); mag files written by cicpy aremagscale 1 2. If you hand-compute coordinates across files, check units against a cell of known size first.
Routing
The route language is shared with the compiler; the full reference is in routes. The short version an agent needs:
Directed routes (addDirectedRoutes in json,
layout.addDirectedRoute in python): ["layer", "net",
"START<type>STOP", "options"] where START/STOP are instance:terminal
regexes and <type> is drawn with dashes and pipes:
| type | meaning |
|---|---|
-\|-- |
left: horizontal, vertical, horizontal, cuts left aligned |
--\|- |
right: same, cuts right aligned |
- , -> |
straight horizontal |
\|\| |
straight vertical |
-\| / \|- |
U shapes right / left |
--\| / \|-- |
U shapes top / bottom |
-\|- |
no alignment of its own, the options decide (straight, strap, …) |
>-\|-- |
left with the start offset low one routing width |
Common options: straight, strap, leftdownleftup, leftupleftdown,
offsetlow, offsethigh (+end variants), 2cuts, 2vcuts,
cutalignright, noport, onTop, routingWidth<n>.
Connectivity routes (addOrthogonalConnectivityRoute(vertLayer,
horizLayer, netRegex, options, cuts)): routes a whole net using the
access geometry the devices already expose. Options place the horizontal
bar: track<n> counts routing tracks (negative counts from the other
side), onTopLeft/onTopRight/onTopB pick the attachment side.
Pin geometry comes from the port’s own layer; accessLayer=X in the
options attaches at a pin’s same-net metal on X instead of stacking
from the pin layer.
Power and ports, the pattern from a finished cell:
def beforeRoute(layout):
layout.addRouteRing("M1", "VDD_1V8", "t", widthmult=3, spacemult=2)
layout.addRouteRing("M1", "VSS", "b", widthmult=3, spacemult=2)
layout.addPowerConnection("VDD_1V8", "", "top")
layout.addPowerConnection("VSS", "", "bottom")
scope = layout._route_scopes["p_in"] # stack-local routes first
scope.addOrthogonalConnectivityRoute("M2", "M3", "^IBP$", "track-2,onTopLeft", 1)
layout.addOrthogonalConnectivityRoute("M4", "M3", "^VO$", "onTopLeft,track4", 1, "", "")
def afterPorts(layout):
layout.addPortOnEdge("M2", "VO", "top", "||", "")
layout.addPortOnEdge("M3", "IBP", "left", "|-", "track0")
Route debugging: sch2mag prints a route short report naming the shorted
nets and the python callsite that drew the offending route. For opens and
split nets run sch2mag --check-connectivity, it is slower and not the
default loop.
Look before you route
A track number is an offset from the net’s own pins:
trunk_x = anchor_right + (track + 1) * vspace + track * vwidth
base_y = min(rect.centerY() for rect in self.accessRects)
so two nets whose pins share a column compute nearly the same anchor and land on each other at the same track number, and neither can tell. The only way to discover that from the route language is to draw it and read the short report, which costs a full regeneration per guess. Do not route that way. Ask first:
cicpy tracks <cic> <tech> <cell> --layer M3
cicpy tracks <cic> <tech> <cell> --layer M3 --band 279000:363000
cicpy tracks <cic> <tech> <cell> --layer M4 --free 217000:609000
The MCP tool tracks is the same thing. --free LO:HI is usually the
question you actually have: a track carrying a short wire at one end is
still usable at the other, so whole-empty tracks understate the budget.
This is worth doing before the first route, not after the first short.
LELOTEMP_OTAR spent an evening at six opens with every horizontal bar
fighting inside the device rows, and one query showed why: the 84 um
channel the placement had opened between the rows held 21 M3 tracks and
all 21 were free. Nothing ever sent a bar there, because base_y comes
from the net’s own pins.
Aim at a channel, never at a coordinate
Register the gaps the placement makes, then route to them by name and
index. In afterPlace:
layout.addRoutingChannel("mid", nmos.y2, pmos.y1)
layout.addRoutingChannel("bias", p_bias.x1, p_bias.x2, horizontal=False)
and in beforeRoute:
layout.addOrthogonalConnectivityRoute(
"M4", "M3", "^VO$", "hchannel=mid,htrack=5,vchannel=bias,vtrack=8",
1, "", "")
hchannel/htrack place the horizontal bar, vchannel/vtrack the
trunk, and both may appear together. The registration holds the only
numbers and they come from the placement that just ran, so the cell
still moves to another technology and survives a resize.
Never write bandy/trunkx in a design. They exist as the resolved
form of the above and a coordinate in a pycell outlives nothing.
Ask before you draw
Three questions, each answerable without a regeneration. The old loop – draw a guess, rebuild, read the short report – costs a full rebuild per guess, and five of them were spent on one net before any of these existed.
tracks which corridor is free, and where
blockers what stops THIS net from dropping a via column HERE
findroute is there a way through at all, and what does it cost
blockers is the one that is not obvious. When a route shorts and the
track report looks clean, the collision is almost never on one layer: a
trunk on M4 and a pin on M1 never share a track, so a same-layer check
reports nothing. What collides is the via COLUMN – a route reaching a
pin comes down through every layer at that x, and any other net’s pin in
the way is shorted. Every routing failure measured in LELOTEMP_OTAR was
that, four separate times.
Two facts that fall out of it and are worth carrying:
- Ask the technology for the via size, never assume it. The sky130
1x1 cut is 4000 square. A guess of 8800, carried over from a note about
pad clashes, made the router declare every ladder net in
LELOTEMP_OTAR unroutable: it could not leave a pin, because it believed
a pad centred on one terminal covered the neighbour 4000 away.
Cut.getInstance(a, b, 1, 1).width()is the answer. - A via occupies only the layers it connects. A whole descent from M4 down to a pin passes through everything on the way; one M1->M2 step does not. Treating a single via as claiming the full column makes it illegal to via beneath any unrelated upper-layer wire, which is not a short in any technology.
- Bars land where the pins are, not where there is room. A plain route takes its bar height from the net’s own pins, so bars fight inside device rows while the channel between them sits empty. Measured: one bar inside the pmos row with 27 free M3 tracks in the channel it should have used.
Router facts that cost a day to learn
- One net per row channel, unless you place them. The router lays a
horizontal bar per device row and puts every bar of a channel at the
same height; the plain
trackoption does not separate them, because it is relative to each net’s own pins. Two nets whose bars share a row channel with overlapping x short. Either keep nets column local (vertical bundle rails via routeDiodeConnected/routeMirror), or give the crossing ones a named channel track each, which is what the channel is for. - routeMirror rails do not stagger. A column with several nets on the same terminal puts all their rails on the same x. Until the router staggers rails, such columns cannot be bundle routed.
- Series chains cannot overlap stack. The transistor cells carry full height M2 rails, and at the overlap pitch neighbouring cells merge them, which shorts a ladder end to end. DRC does not see it, the connectivity check does.
Verification beyond DRC
cicpy checkroutes <cic> <tech> <cell> reports shorts and opens from a
.cic that is already on disk, in about a second and without touching a
file. Use it after every routing change. The MCP connectivity tool
re-runs sch2mag, which replaces the layout it is asked about: right
for an sch2mag design, wrong for a ciccreator library, and it will
overwrite the .mag you were checking.
A tap-less leaf cell reports its supply rails split. That is the library design, not a defect.
make gds cdl lvsis the full check; LVS needs the gds regenerated first or the extraction runs against a stale state and the result is meaningless.- DRC cannot see shorts. Restored or added metal that crosses another net is invisible to DRC and only LVS catches it.
Worked example
LELO_TEMP_SKY130A/LELOTEMP_OTA.py in lelo_temp_sky130a exercises all of
this: netlist driven grouping with renames, folded ten device groups into
mirrored five device halves, dummy fill, tap fallback for LVT devices,
and every spacing in the table above. LELOTEMP_CMP.py in the same
library is the reference for the routing phase.