Skip to content

Download notebook (.ipynb)

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

  1. Units: DBU vs µm
  2. Ports: port= keyword is required
  3. Layers & KCLayout initialisation
  4. Caching: arguments must be hashable
  5. Cross-sections in cached cells
  6. Factory parameter units
  7. Effective bend radius vs nominal radius
  8. Routing: suppress collision errors in headless builds
  9. PDK: always pass kcl= to kf.Port
  10. Enclosures: µm sections need kcl=
  11. fill_tiled: call inside @kf.cell, result is in-place
  12. Packing: spacing and limits are in DBU
  13. 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_dbu to route_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:

  1. Call fill_tiled inside the decorated function — the target cell must be unlocked. After @kf.cell caches and freezes the cell you can no longer modify it.
  2. fill_tiled returns None — 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