Best Practices & Common Pitfalls
This guide collects the most frequent mistakes kfactory users encounter, with the correct patterns shown for each one. Every section is self-contained and executable.
Contents
- Units: DBU vs µm
- Ports:
port=keyword is required - Layers & KCLayout initialisation
- Caching: arguments must be hashable
- Cross-sections in cached cells
- Factory parameter units
- Effective bend radius vs nominal radius
- Routing: suppress collision errors in headless builds
- PDK: always pass
kcl=tokf.Port - Enclosures: µm sections need
kcl= - fill_tiled: call inside
@kf.cell, result is in-place - Packing: spacing and limits are in DBU
- dmove: pass a tuple, not a DVector
import kfactory as kf
import kfactory.routing.optical as opt
class LAYER(kf.LayerInfos):
WG: kf.kdb.LayerInfo = kf.kdb.LayerInfo(1, 0)
WGEX: kf.kdb.LayerInfo = kf.kdb.LayerInfo(2, 0)
SLAB: kf.kdb.LayerInfo = kf.kdb.LayerInfo(3, 0)
L = LAYER()
kf.kcl.infos = L
li_wg = kf.kcl.find_layer(L.WG)
li_wgex = kf.kcl.find_layer(L.WGEX)
1 · Units: DBU vs µm
kfactory (and KLayout) has two parallel coordinate systems:
| System | Unit | Python type | Rule of thumb |
|---|---|---|---|
| DBU (database units) | 1 nm (default) | int |
Use inside cell logic, ports, boolean ops |
| µm (micrometres) | 1 µm | float |
Use for user-facing parameters |
The conversion is fixed: 1 µm = 1 000 DBU (kf.kcl.dbu = 0.001 µm).
Common mistake — passing µm where DBU is expected
# WRONG: 0.5 is treated as 0 DBU (rounds to integer)
c.shapes(li_wg).insert(kf.kdb.Box(10.0, 0.5))
# WRONG: width=0.5 sets port to 0 or 1 DBU, not 500 nm
kf.Port(name="o1", width=0.5, ...)
Correct pattern
# Convert µm → DBU explicitly at the boundary
width_um = 0.5 # user-facing µm value
length_um = 10.0
w_dbu = kf.kcl.to_dbu(width_um) # → 500 (int)
l_dbu = kf.kcl.to_dbu(length_um) # → 10000 (int)
c = kf.KCell("bp_units_demo")
c.shapes(li_wg).insert(kf.kdb.Box(l_dbu, w_dbu))
c.add_port(
port=kf.Port(
name="o1",
trans=kf.kdb.Trans(2, False, -l_dbu // 2, 0),
width=w_dbu, # ← integer DBU
layer=li_wg,
port_type="optical",
)
)
c.add_port(
port=kf.Port(
name="o2",
trans=kf.kdb.Trans(0, False, l_dbu // 2, 0),
width=w_dbu,
layer=li_wg,
port_type="optical",
)
)
print(f"dbu setting : {kf.kcl.dbu} µm/DBU")
print(f"width → DBU : {w_dbu}")
print(f"bbox (DBU) : {c.bbox()}")
print(f"bbox (µm) : {c.dbbox()}")
dbu setting : 0.001 µm/DBU
width → DBU : 500
bbox (DBU) : (-5000,-250;5000,250)
bbox (µm) : (-5,-0.25;5,0.25)
2 · Ports: port= keyword is required
KCell.add_port() takes the port via the keyword argument port=.
Passing it positionally raises a TypeError.
# WRONG — positional argument
c.add_port(my_port)
# WRONG — wrong keyword
c.add_port(p=my_port)
# CORRECT — always use port=
example = kf.KCell("bp_add_port")
p = kf.Port(
name="o1", trans=kf.kdb.Trans(0), width=500, layer=li_wg, port_type="optical"
)
example.add_port(port=p) # ← keyword required
# Renaming when exposing a child port on a parent cell:
parent = kf.KCell("bp_parent")
inst = parent << example
parent.add_port(port=inst.ports["o1"], name="in") # ← name= renames
print(f"parent ports: {[p.name for p in parent.ports]}")
parent ports: ['in']
3 · Layers & KCLayout initialisation
Pass the class (not an instance) as infos=
KCLayout.__init__ calls infos() internally, so you must pass the class:
# WRONG — passes an instance; KCLayout calls LAYER()() which fails
pdk = kf.KCLayout("mypdk", infos=LAYER())
# CORRECT — pass the class
pdk = kf.KCLayout("mypdk", infos=LAYER)
Setting infos after construction does not fully register layers
# Full correct initialisation for a new KCLayout
pdk = kf.KCLayout("best_practices_pdk", infos=LAYER)
L2 = pdk.infos # already an instance; use this for layer lookups
print(f"pdk.dbu : {pdk.dbu}")
print(f"layer WG : {pdk.find_layer(L2.WG)}")
pdk.dbu : 0.001
layer WG : WG
layerenum_from_dict lives in kfactory.layer, not top-level kf
# WRONG
kf.layerenum_from_dict(...)
# CORRECT
from kfactory.layer import layerenum_from_dict
layerenum_from_dict(...)
4 · Caching: arguments must be hashable
@kf.cell identifies unique cells by hashing all arguments. Any
unhashable argument (list, dict, LayerInfo object, CrossSection object)
raises TypeError at call time.
Rules:
- Use int, float, str, bool, tuple, or frozen containers.
- Replace a CrossSection argument with its name string; look it up
inside the function.
- Replace a LayerInfo with a str name or int layer index.
# Register a cross-section: call get_icross_section() with a spec to store it,
# then retrieve it by name inside the factory.
_xs_spec = kf.DCrossSection(
kcl=kf.kcl,
width=0.5,
layer=L.WG,
sections=[(L.WGEX, 1.0)], # 1 µm cladding
name="wg_500",
)
kf.kcl.get_icross_section(_xs_spec) # ← registers under the name "wg_500"
@kf.cell
def waveguide_xs(xs_name: str = "wg_500", length_um: float = 10.0) -> kf.KCell:
"""Waveguide using a cross-section looked up by name."""
xs = kf.kcl.get_icross_section(xs_name) # ← resolve here, not in signature
c = kf.KCell()
l = kf.kcl.to_dbu(length_um)
w = xs.width # already in DBU
li = kf.kcl.find_layer(xs.main_layer)
c.shapes(li).insert(kf.kdb.Box(l, w))
c.add_port(
port=kf.Port(
name="o1",
trans=kf.kdb.Trans(2, False, -l // 2, 0),
width=w,
layer=li,
port_type="optical",
)
)
c.add_port(
port=kf.Port(
name="o2",
trans=kf.kdb.Trans(0, False, l // 2, 0),
width=w,
layer=li,
port_type="optical",
)
)
return c
wg_a = waveguide_xs(xs_name="wg_500", length_um=10.0)
wg_b = waveguide_xs(xs_name="wg_500", length_um=10.0)
print(f"Same params → same object: {wg_a is wg_b}") # True
Same params → same object: True
5 · Cross-sections in cached cells
Passing a CrossSection or DCrossSection object directly as a parameter
raises TypeError: unhashable type because cross-section objects are not
hashable. Always pass the name string and resolve inside the function
(see §4 above).
6 · Factory parameter units
Each factory function uses a specific unit system for its parameters. Getting this wrong produces silently wrong geometry.
| Factory | Width | Length / Radius | Unit |
|---|---|---|---|
straight_dbu_factory |
DBU (int) |
DBU (int) |
nm |
taper_factory |
DBU (int) |
DBU (int) |
nm |
bend_euler_factory |
µm (float) |
µm (float) |
µm |
bend_circular_factory |
µm (float) |
µm (float) |
µm |
bend_s_euler_factory |
µm (float) |
µm (float) |
µm |
bezier_factory |
µm (float) |
µm (float) |
µm |
# Use a dedicated layout for the factory demo to avoid global state conflicts
fac_pdk = kf.KCLayout("BP_FAC_DEMO", infos=LAYER)
straight_f = kf.factories.straight.straight_dbu_factory(fac_pdk)
bend_euler_f = kf.factories.euler.bend_euler_factory(fac_pdk)
# straight_dbu_factory: width and length in DBU (use to_dbu to convert µm)
s = straight_f(
width=fac_pdk.to_dbu(0.5), # 500 DBU = 0.5 µm
length=fac_pdk.to_dbu(10.0), # 10000 DBU = 10 µm
layer=L.WG,
)
print(f"straight bbox (DBU): {s.bbox()}")
# bend_euler_factory: width and radius in µm (no to_dbu needed)
b = bend_euler_f(width=0.5, radius=10.0, layer=L.WG)
print(f"euler bbox (µm): {b.dbbox()}")
straight bbox (DBU): (0,-250;10000,250)
euler bbox (µm): (0,-0.25;18.951,18.701)
7 · Effective bend radius vs nominal radius
Euler (clothoid) bends extend further than their nominal radius because the
clothoid transitions ramp up gradually. Always use
kf.routing.optical.get_radius(bend_cell.ports) to get the footprint radius
in DBU that routing algorithms need. Convert to µm before comparing with
the nominal radius passed to the factory.
Circular bends have the same footprint and nominal radius, up to DBU rounding.
bend90 = bend_euler_f(width=0.5, radius=10.0, layer=L.WG)
nominal_radius = 10.0 # what we asked for
footprint_radius_dbu = opt.get_radius(bend90.ports) # what DBU routing needs
footprint_radius_um = bend90.kcl.to_um(footprint_radius_dbu)
print(f"nominal radius : {nominal_radius:.3f} µm")
print(f"footprint radius: {footprint_radius_um:.3f} µm")
print(f"difference : {footprint_radius_um - nominal_radius:.3f} µm")
nominal radius : 10.000 µm
footprint radius: 18.701 µm
difference : 8.701 µm
Rule: Pass
footprint_radius_dbutoroute_loopback(bend90_radius=...),place_manhattan(...), and similar functions. Using the nominal value causes "distance too small" errors.
8 · Routing: suppress collision errors in headless builds
By default, route_bundle enables collision detection and calls
kf.show() / KLayout's error dialog when routes overlap. In a headless
documentation build (no display, no KLayout window) this hangs or crashes.
Pass on_collision=None to disable the callback:
# Headless-safe (docs, CI, testing)
kf.routing.optical.route_bundle(
...,
on_collision=None,
)
# Interactive (development): keep default or pass on_collision="show"
kf.routing.optical.route_bundle(...)
Note: Only suppress when you are confident the geometry is correct. Leave collision detection on during development so errors are caught early.
9 · PDK: always pass kcl= to kf.Port
When building cells inside a custom KCLayout (PDK), ports created with
kf.Port(...) default to the global kf.kcl layout. Layer indices in
the PDK layout will differ, causing silent mismatches or runtime errors.
# WRONG — port attached to global kf.kcl, not pdk
p = kf.Port(name="o1", layer=pdk.find_layer(L.WG), ...)
# CORRECT — port attached to the correct layout
p = kf.Port(name="o1", layer=pdk.find_layer(L.WG), kcl=pdk, ...)
# Demonstrate: ports use pdk layout, not global kf.kcl
@pdk.cell
def pdk_straight(width: float = 0.5, length: float = 10.0) -> kf.KCell:
c = kf.KCell(kcl=pdk)
w = pdk.to_dbu(width)
ll = pdk.to_dbu(length)
li = pdk.find_layer(L2.WG)
c.shapes(li).insert(kf.kdb.Box(ll, w))
c.add_port(
port=kf.Port(
name="o1",
trans=kf.kdb.Trans(2, False, -ll // 2, 0),
width=w,
layer=li,
port_type="optical",
kcl=pdk, # ← attach to PDK layout
)
)
c.add_port(
port=kf.Port(
name="o2",
trans=kf.kdb.Trans(0, False, ll // 2, 0),
width=w,
layer=li,
port_type="optical",
kcl=pdk, # ← attach to PDK layout
)
)
return c
ps = pdk_straight()
print(f"PDK cell layout : {ps.kcl.name}")
print(f"Port o1 layout : {ps.ports['o1'].kcl.name}")
PDK cell layout : best_practices_pdk
Port o1 layout : best_practices_pdk
10 · Enclosures: µm sections need kcl=
LayerEnclosure accepts sections in either DBU or µm. When using the
dsections= (µm) form, the enclosure needs a reference KCLayout to
convert µm → DBU. Omitting kcl= raises AttributeError at apply time.
# WRONG — dsections without kcl=
enc = kf.LayerEnclosure(dsections=[(L.WGEX, 1.0)])
# CORRECT — provide kcl= so conversion is possible
enc = kf.LayerEnclosure(dsections=[(L.WGEX, 1.0)], kcl=kf.kcl)
Three-element sections create annular (ring) cladding
# Two-element (layer, d) → expand outward by d (DBU)
enc_solid = kf.LayerEnclosure(sections=[(L.WGEX, 500)])
# Three-element (layer, d_min, d_max) → ring from d_min to d_max (DBU)
enc_ring = kf.LayerEnclosure(sections=[(L.SLAB, 0, 2000)])
# Correct dsections usage with kcl=
enc_ok = kf.LayerEnclosure(dsections=[(L.WGEX, 1.0)], kcl=kf.kcl)
print(f"enclosure layer_sections: {enc_ok.layer_sections}")
# Three-element annular section demo (annular ring: d_min=0, d_max=2 µm)
enc_ring = kf.LayerEnclosure(sections=[(L.SLAB, 0, 2000)], kcl=kf.kcl)
print(f"ring layer_sections: {enc_ring.layer_sections}")
enclosure layer_sections: {WGEX (2/0): LayerSection(sections=[Section(d_min=None, d_max=1000)])}
ring layer_sections: {SLAB (3/0): LayerSection(sections=[Section(d_min=0, d_max=2000)])}
11 · fill_tiled: call inside @kf.cell, result is in-place
Two rules that catch almost everyone:
- Call
fill_tiledinside the decorated function — the target cell must be unlocked. After@kf.cellcaches and freezes the cell you can no longer modify it. fill_tiledreturnsNone— it modifies the cell in-place. Do not assign its return value.
# WRONG — called after caching (cell is frozen)
my_cell = make_fill_cell()
kf.fill_tiled(my_cell, ...) # AttributeError: cell is read-only
# WRONG — return value assigned (it's None)
region = kf.fill_tiled(c, ...) # region is None
# CORRECT — call inside the factory function
@kf.cell
def fill_block(width_um: float, height_um: float) -> kf.KCell:
c = kf.KCell()
...
kf.fill_tiled(c, ...) # ← in-place, returns None
return c
Also note:
- row_step / col_step are kdb.DVector in µm.
- x_space / y_space are µm gaps between bounding boxes.
- @kf.cell(kcl=...) is not valid syntax — create cells with
kf.KCell(kcl=...) explicitly inside the function body.
12 · Packing: spacing and limits are in DBU
kf.packing.pack_kcells and kf.packing.pack_instances live in the
kf.packing sub-module (not top-level kf). Their spacing,
max_width, and max_height parameters are all in DBU.
from kfactory import packing
# Build a handful of small cells to pack
cells = [kf.KCell(f"pack_demo_{i}") for i in range(5)]
for i, cell in enumerate(cells):
cell.shapes(li_wg).insert(
kf.kdb.Box(
kf.kcl.to_dbu((i + 1) * 5.0), # 5, 10, 15, 20, 25 µm wide
kf.kcl.to_dbu(2.0), # 2 µm tall
)
)
container = kf.KCell("pack_container")
packing.pack_kcells(
kcells=cells,
target=container,
spacing=kf.kcl.to_dbu(2.0), # ← DBU (use to_dbu to convert from µm)
max_width=kf.kcl.to_dbu(100.0), # ← DBU
)
print(f"packed bbox (µm): {container.dbbox()}")
packed bbox (µm): (1,1;84,3)
13 · dmove: pass a tuple, not a DVector
Instance.dmove() accepts a (dx, dy) tuple of µm floats. Passing a
kdb.DVector raises TypeError because the method tries to unpack it as
a two-element iterable and gets a CplxTrans argument error.
# WRONG — DVector causes TypeError with DCplxTrans unpacking
inst.dmove(kf.kdb.DVector(5.0, 0.0))
# CORRECT — plain tuple
inst.dmove((5.0, 0.0))
tile = kf.KCell("dmove_demo_tile")
tile.shapes(li_wg).insert(kf.kdb.Box(2000, 500))
canvas = kf.KCell("dmove_demo_canvas")
for i in range(4):
inst = canvas << tile
inst.dmove((i * 3.0, 0.0)) # ← tuple, not DVector
print(f"canvas bbox (µm): {canvas.dbbox()}")
canvas bbox (µm): (-1,-0.25;10,0.25)
Quick-reference summary
| Pitfall | Rule |
|---|---|
| µm vs DBU confusion | Convert at function boundary with kf.kcl.to_dbu() |
add_port errors |
Always use c.add_port(port=p) keyword form |
KCLayout infos= |
Pass the class (infos=LAYER), not an instance |
Unhashable @kf.cell args |
Pass cross-sections / layers as name strings |
| Wrong factory units | straight_dbu_factory → DBU; euler/circular → µm |
| Routing with euler bends | Use opt.get_radius(bend.ports) for footprint radius in DBU |
| Headless collision errors | Pass on_collision=None in CI / doc builds |
| PDK port layout mismatch | Always pass kcl=pdk to kf.Port(...) |
| Enclosure µm sections | Pass kcl= to LayerEnclosure(dsections=...) |
fill_tiled usage |
Call inside @kf.cell; return value is None |
| Packing parameters | spacing, max_width, max_height are in DBU |
dmove argument |
Pass (dx, dy) tuple, not kdb.DVector |
See Also
| Topic | Where |
|---|---|
| Common patterns (positive guidance) | How-To: Patterns |
| Frequently asked questions | How-To: FAQ |
| DBU vs µm coordinate systems | Core Concepts: DBU vs µm |
| Creating a full PDK | PDK: Creating a PDK |