Layout vs. Schematic (LVS)

A Visual, Step-by-Step Teaching Guide

This notebook walks through PhotonForge’s LVS workflow visually, building up from simple building blocks to a fully verified circuit. At each stage we inspect the netlist and render the layout as SVG.

c7476ab6766143ffa3e16ea819a1435c

What LVS means in PhotonForge

In electronics, shape overlap is sufficient to determine connectivity, touching metal layers form a connection. In photonics this breaks down: two waveguides can overlap and form a 4-port directional coupler, not a short. PhotonForge therefore determines connectivity through port proximity matching, only co-located ports with compatible specifications form a connection.

The LVS workflow answers three questions:

Question

PhotonForge mechanism

Are all intended connections physically routed?

Virtual connections → routes

Are any ports left dangling?

get_netlist() warnings

Are there unintended geometry overlaps?

pf.boolean(..., "*") + COLLISION layer


Notebook Stages

  1. Setup — imports, PDK

  2. Building blocks — spiral and coupler

  3. Assembly — auto-detected physical connection

  4. Netlist with warnings — dangling ports signal missing intent

  5. Virtual connections — declaring schematic intent

  6. Routing — replacing virtual connections with physical waveguides

  7. Collision map — ustom COLLISION layer, visual overlap detection on custom COLLISION layer

  8. Fixing intersections — spacing + route update

  9. Final LVS check — clean netlist

  10. Alternative: component_from_netlist declarative approach

1 · Setup

[1]:
import warnings

import photonforge as pf
from photonforge.live_viewer import LiveViewer

# Start from the built-in demo PDK
tech = pf.basic_technology()
viewer = LiveViewer()

pf.config.default_technology = tech
pf.config.default_kwargs = {
    "port_spec": "Strip",
    "radius": 10,
    "coupling_distance": 0.6,
}

print("Technology layers:", list(tech.layers.keys()))
LiveViewer started at http://localhost:36029
Technology layers: ['METAL', 'SLAB', 'TRENCH', 'WG_CLAD', 'WG_CORE']

Feel free to click the live viewer link to view the components in a separate browser. You just need to call viewer(component_name)

2 · Building Blocks

We create two reusable parametric components:

  • Rectangular spiral — a delay line with four ports

  • S-bend coupler — a 4-port directional coupler

Jupyter renders components directly as SVG via _repr_svg_(). Port arrows show direction and mode specification.

[2]:
spiral = pf.parametric.rectangular_spiral(full_length=700)
spiral
[2]:
../_images/guides_lvs_guide_5_0.svg
[3]:
coupler = pf.parametric.s_bend_coupler(coupling_length=2, s_bend_offset=2)
coupler
[3]:
../_images/guides_lvs_guide_6_0.svg

3 · Assembly — Physical Connection Auto-Detection

We assemble a parent component c with two coupler instances and one spiral.

Key concept: We place spiral0 by translating it so its P0 port lands exactly on coupler0’s P3 port. No explicit connection declaration is needed — PhotonForge detects touching ports automatically when get_netlist() is called.

coupler1 is placed only 2 µm below coupler0intentionally too close to demonstrate intersection detection in Stage 7.

[4]:
c = pf.Component("Main")

coupler0 = c.add_reference(coupler)
coupler1 = c.add_reference(coupler)
spiral0 = c.add_reference(spiral)

# Place coupler1 too close on purpose — will cause intersection after routing
coupler1.y_max = coupler0.y_min - 2

# Align spiral P0 to coupler0 P3 — creates a physical connection
spiral0.connect("P0", coupler0["P3"])

c
[4]:
../_images/guides_lvs_guide_8_0.svg

4 · Netlist Inspection — Warnings Signal Dangling Ports

get_netlist() inspects the layout geometry and returns the circuit connectivity. It emits RuntimeWarnings for any ports that are neither connected nor exposed as external ports.

These warnings are LVS signals — they tell you the schematic intent is incomplete.

[5]:
net = c.get_netlist()

print("=== Connections (auto-detected from touching ports) ===")
for conn in net["connections"]:
    print(" ", conn)

print("\n=== Virtual connections ===")
print(" ", net["virtual connections"])

print("\n=== Exposed ports ===")
print(" ", list(net["ports"].keys()))
=== Connections (auto-detected from touching ports) ===
  ((0, 'P3', 1), (2, 'P0', 1))

=== Virtual connections ===
  []

=== Exposed ports ===
  []
/tmp/ipykernel_1615505/426905997.py:1: RuntimeWarning: These ports are not connected and will be ignored: 'P0' at (0, -9.1) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P2' at (19.436, 0) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P0' at (0, 0) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P1' at (0, 4.6) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P3' at (19.436, -4.5) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P2' at (19.436, -9.1) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P1' at (0, -4.5) from reference to 's_bend_coupler_UORECE6IWK4BE5JXAIZIXVKFZQOZHHV25BAS67WQ7G3YZ2JXDJZQ', 'P1' at (61.936, 64.973) from reference to 'rectangular_spiral_6RK3G4IGQFDYWHAPLN225ER7CVZOHIS2LE25WOAZWHX5K43ZOHIA'.
  net = c.get_netlist()

What we see:

  • One physical connection found: coupler0.P3 spiral0.P0 — detected automatically because those ports are co-located.

  • Warning about dangling ports — the remaining ports have no declared intent.


5 · Virtual Connections — Declaring Schematic Intent

Virtual connections are schematic-level annotations that say “I intend to route a waveguide between these two ports.” They are not physical geometry since they appear as dashed lines in the SVG.

Adding virtual connections + exposing the I/O ports makes the netlist warning-free.

[6]:
# Declare routing intent
c.add_virtual_connection(spiral0, "P1", coupler1, "P2")
c.add_virtual_connection(coupler0, "P2", coupler1, "P3")

# Expose the four external I/O ports
c.add_port([coupler0["P0"], coupler0["P1"], coupler1["P0"], coupler1["P1"]])

# Virtual connections show as dashed purple lines in the SVG
c
[6]:
../_images/guides_lvs_guide_12_0.svg
[7]:
net = c.get_netlist()  # no warnings now
print("Physical connections :", net["connections"])
print("Virtual connections  :", net["virtual connections"])
print("Exposed ports        :", list(net["ports"].keys()))
Physical connections : [((0, 'P3', 1), (2, 'P0', 1))]
Virtual connections  : [((2, 'P1', 1), (1, 'P2', 1)), ((0, 'P2', 1), (1, 'P3', 1))]
Exposed ports        : [(1, 'P1', 1), (1, 'P0', 1), (0, 'P1', 1), (0, 'P0', 1)]

What we see:

  • No more warnings so every port is either connected or declared.

  • Two virtual connections representing our routing intent.

  • Four exposed I/O ports.


6 · Routing — Replacing Virtual Connections with Physical Waveguides

For each virtual connection we add a pf.parametric.route(...) and remove the virtual connection. One virtual connection becomes two physical connections in the netlist (one at each route endpoint).

[8]:
# Route 1: spiral P1  →  coupler1 P2
c.add(pf.parametric.route(port1=(spiral0, "P1"), port2=(coupler1, "P2")))
c.remove_virtual_connection(spiral0, "P1")

c  # one VC replaced, one still shown as dashed
[8]:
../_images/guides_lvs_guide_15_0.svg
[9]:
# Route 2: coupler0 P2  →  coupler1 P3
c.add(pf.parametric.route(port1=(coupler0, "P2"), port2=(coupler1, "P3")))
c.remove_virtual_connection(coupler0, "P2")

c  # all virtual connections replaced — fully routed
[9]:
../_images/guides_lvs_guide_16_0.svg
[10]:
net_routed = c.get_netlist()
print("Virtual connections remaining :", net_routed["virtual connections"])
print("Physical connections          :", len(net_routed["connections"]))
for conn in net_routed["connections"]:
    print(" ", conn)
Virtual connections remaining : []
Physical connections          : 5
  ((2, 'P1', 1), (3, 'P0', 1))
  ((1, 'P2', 1), (3, 'P1', 1))
  ((1, 'P3', 1), (4, 'P1', 1))
  ((0, 'P2', 1), (4, 'P0', 1))
  ((0, 'P3', 1), (2, 'P0', 1))

What we see:

  • Zero virtual connections — all intent is physically realized.

  • 5 physical connections: 1 original (spiral↔coupler0) + 2 routes × 2 endpoints each.

But we’re not done! The netlist only checks port connectivity, it cannot detect geometry overlaps.


7 · Collision Map — Visual Intersection Detection

Because we placed coupler1 only 2 µm below coupler0, the routed waveguides physically overlap. The netlist looks completely clean — but the layout has a fabrication problem invisible to connectivity analysis.

EDA analogy: Unintended waveguide overlaps are analogous to parasitic coupling in RF. They are a DRC/PEX concern, not an LVS connectivity concern.

We define two functions:

  • ``show_collisions()`` — uses pf.boolean(..., "*") to find all pairwise overlaps, adds the exact intersection shapes on the custom COLLISION layer (orangered cross-hatch), and returns a visualization component you can render.

  • ``verify_intersections()`` — thin wrapper that raises RuntimeError if any collision exists; used for final LVS sign-off.

[11]:
# Add a custom COLLISION layer for visual overlap highlighting.
# GDS layer (99, 0) is unused by the PDK — safe to repurpose as annotation only.
tech.add_layer(
    "COLLISION",
    pf.LayerSpec(
        layer=(99, 0),
        description="Geometry overlap / DRC collision highlight",
        color="#ff4500cc",  # orangered, mostly opaque
        pattern="xx",  # cross-hatch so it stands out over WG_CORE
    ),
)
[11]:
Name: Basic Technology
Version: 1.3.2
Layers
NameLayerDescriptionColorPattern
WG_CLAD(1, 0)Waveguide clad#9da6a218.
WG_CORE(2, 0)Waveguide core#6db5dd18/
SLAB(3, 0)Slab region#8851ad18:
TRENCH(4, 0)
Deep-etched trench for chip…… facets
#535e5918+
METAL(5, 0)Metal layer#b8a18b18\
COLLISION(99, 0)
Geometry overlap / DRC…… collision highlight
#ff4500ccxx
Extrusion Specs
#MaskLimits (μm)Sidewal (°)Opt. MediumElec. Medium
0'WG_CORE'0, 0.220cSi_Li1993_293KMedium(permittivity=12.3)
1'SLAB'0, 0.070cSi_Li1993_293KMedium(permittivity=12.3)
2'METAL'1.72, 2.220Cu_JohnsonChristy1972PEC
3'TRENCH'-inf, inf0Medium()Medium()
Ports
NameClassificationDescriptionWidth (μm)Limits (μm)Radius (μm)ModesTarget n_effPath profiles (μm)Voltage pathCurrent path
CPWelectricalCPW transmission line26.0552-9.05312, 12.9931014
'gnd0'@……'METAL': 8.9776 (-8.7888), 'gnd1'@'METAL': 8.9776 (+8.7888), 'signal'@'METAL': 3.6
(4.3, 1.97) (1.8, 1.97)
(3.05, 3.47) (-3.05, 3.47)…… (-3.05, 0.47) (3.05, 0.47)
RibopticalRib waveguide2.16-1, 1.22014
'WG_CORE': 0.4,…… 'SLAB': 2.4, 'WG_CLAD': 2.4
StripopticalStrip waveguide2.25-1, 1.22014
'WG_CORE': 0.5,…… 'WG_CLAD': 2.5
Background medium
  • Optical: SiO2_Palik_Lossless
  • Electrical: Medium(permittivity=4.2)
Connections: []
[12]:
def show_collisions(component: pf.Component, layer: str) -> tuple[pf.Component, int]:
    """
    Return a visualization component with all pairwise geometry overlaps
    highlighted on the COLLISION layer (orangered cross-hatch).

    Args:
        component: Component to check.
        layer: Layer to check.

    Returns:
        Original component with collision shapes overlaid, and number
        of collisions found.
    """
    result = pf.Component(f"{component.name} [collision map]")
    result.add_reference(component)

    main = component.get_structures(layer, depth=0)
    refs = component.references
    n_collisions = 0

    def label(idx):
        parts = refs[idx].component_name.split("_")
        name = (
            ("_".join(parts[:-1]) + "_…")
            if len(parts) > 1
            else refs[idx].component_name
        )
        return f"{name} @ {refs[idx].center()}"

    for i, r0 in enumerate(refs):
        s0 = r0.get_structures(layer)
        if main:
            isect = pf.boolean(main, s0, "*")
            if len(isect) > 0:
                result.add("COLLISION", *isect)
                n_collisions += len(isect)
                print(f"  COLLISION: main vs {label(i)}{len(isect)} region(s)")
        for j in range(i + 1, len(refs)):
            r1 = refs[j]
            s1 = r1.get_structures(layer)
            isect = pf.boolean(s1, s0, "*")
            if len(isect) > 0:
                result.add("COLLISION", *isect)
                n_collisions += len(isect)
                print(f"  COLLISION: {label(j)} vs {label(i)}{len(isect)} region(s)")

    if n_collisions == 0:
        print(f"  No collisions found on layer {layer!r}")

    return result, n_collisions


def verify_intersections(component: pf.Component, layer: str):
    """Raise RuntimeError if any geometry overlaps exist. Used for LVS sign-off."""
    _, n = show_collisions(component, layer)
    if n > 0:
        raise RuntimeError(
            f"{n} overlap region(s) on layer {layer!r} — see collision map above."
        )
[13]:
print("Collision report (coupler1 placed only 2 µm below coupler0):")
collision_map, n = show_collisions(c, "WG_CORE")
print(f"\nTotal overlap regions: {n}")
print("The orange regions show WHERE the WG_CORE overlaps.")

# Render — COLLISION layer appears as orangered cross-hatch on top of the layout
viewer(collision_map)
Collision report (coupler1 placed only 2 µm below coupler0):
  COLLISION: route_… @ [32.966 -2.25 ] vs rectangular_spiral_… @ [40.686  34.7865] — 4 region(s)
  COLLISION: route_… @ [32.966 -2.25 ] vs route_… @ [46.311  27.9365] — 2 region(s)

Total overlap regions: 6
The orange regions show WHERE the WG_CORE overlaps.
[13]:
../_images/guides_lvs_guide_21_1.svg

8 · Fixing Intersections — Spacing + Route Update

PhotonForge uses parametric routes that recompute automatically when you call .update() after repositioning a reference. The fix is:

  1. Increase the spacing between coupler0 and coupler1

  2. Call .update() on the affected route references

  3. Re-expose the ports that moved

[14]:
# Increase spacing: 20 µm instead of 2 µm
coupler1.y_max = coupler0.y_min - 20

# Update the two route references (indices 3 and 4 in this assembly)
c.references[3].component.update()
c.references[4].component.update()

# Re-expose the ports that moved with coupler1
c.add_port(coupler1["P0"], "P2")
c.add_port(coupler1["P1"], "P3")

c
[14]:
../_images/guides_lvs_guide_23_0.svg

9 · Final LVS Check — All Three Verifications Pass

A complete LVS sign-off in PhotonForge requires all three checks to pass:

[15]:
# Check 1: No geometry overlaps
print("--- Collision check ---")
try:
    verify_intersections(c, "WG_CORE")
    print("PASS  No geometry intersections on WG_CORE")
except RuntimeError as e:
    print("FAIL ", e)

# Check 2 & 3: Netlist — no warnings, no virtual connections
print("\n--- Netlist check ---")
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    net_final = c.get_netlist()

if caught:
    print("FAIL  Dangling port warnings:")
    for w in caught:
        print("     ", str(w.message))
else:
    print("PASS  No dangling port warnings")

if net_final["virtual connections"]:
    print("FAIL  Unrouted virtual connections:", net_final["virtual connections"])
else:
    print("PASS  All virtual connections are physically routed")

print()
print("=== LVS PASSED ===")
--- Collision check ---
  No collisions found on layer 'WG_CORE'
PASS  No geometry intersections on WG_CORE

--- Netlist check ---
PASS  No dangling port warnings
PASS  All virtual connections are physically routed

=== LVS PASSED ===

10 · Alternative Approach: component_from_netlist

Instead of building a component imperatively, PhotonForge supports a netlist-driven layout that specifies the full circuit in one shot.

Key

What it does

instances

Sub-component references (can set x_reflection, origin, etc.)

connections

Physical port snapping (auto-translate to touch)

routes

Auto-routed waveguide connections (replaces virtual + route calls)

ports

Externally exposed ports

models

Simulation models to attach

Here we build a ring-resonator-style circuit: a coupler with a spiral delay line looping from output P2 back to output P3.

[16]:
netlist_dict = {
    "name": "Ring Resonator Circuit",
    "instances": {
        "coupler": coupler,
        "delay": spiral,
    },
    "connections": [
        (("coupler", "P3"), ("delay", "P0")),  # physical snap
    ],
    "routes": [
        (("delay", "P1"), ("coupler", "P2")),  # auto-route feedback
    ],
    "ports": [
        ("coupler", "P0"),
        ("coupler", "P1"),
    ],
}

circuit = pf.component_from_netlist(netlist_dict)
circuit
[16]:
../_images/guides_lvs_guide_27_0.svg
[17]:
# Full LVS verification on the declarative circuit
print("--- Collision check ---")
try:
    verify_intersections(circuit, "WG_CORE")
    print("PASS  No geometry intersections")
except RuntimeError as e:
    print("FAIL ", e)

print("\n--- Netlist check ---")
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    net_c = circuit.get_netlist()

print("Dangling warnings      :", len(caught))
print("Virtual connections    :", net_c["virtual connections"])
print("Physical connections   :", len(net_c["connections"]))

print()
print("=== LVS PASSED ===")
--- Collision check ---
  No collisions found on layer 'WG_CORE'
PASS  No geometry intersections

--- Netlist check ---
Dangling warnings      : 0
Virtual connections    : []
Physical connections   : 3

=== LVS PASSED ===

Summary

Stage

What happened

LVS signal

Assembly

Placed 3 references; spiral aligned to coupler port

Auto-detected 1 physical connection

Dangling ports

get_netlist() warned about unhandled ports

Warning = incomplete schematic intent

Virtual connections

Declared routing intent for 2 connections

Warnings cleared; VCs visible as dashed lines

Routing

Replaced VCs with physical waveguide routes

VCs → 0; physical connections → 5

Collision detected

Coupler spacing too tight → route overlap

show_collisions() — orangered regions on COLLISION layer

Fix

Increased spacing; called .update() on routes

Collision map clean

LVS passed

All three checks green

No collisions · No warnings · No VCs

The three LVS mechanisms

get_netlist()              →  port connectivity + dangling port warnings
virtual connections        →  schematic intent; must reach zero by tapeout
show_collisions()          →  geometry overlap: exact regions on COLLISION layer

Custom COLLISION layer

tech.add_layer(
    "COLLISION",
    pf.LayerSpec(
        layer=(99, 0),          # unused GDS layer — annotation only
        description="Overlap highlight",
        color="#ff4500cc",      # orangered
        pattern="xx",           # cross-hatch
    ),
)
# Then paint overlaps: vis.add("COLLISION", intersection_polygon)

EDA analogy recap

Electronics

PhotonForge

Connectivity rule

Shape/layer overlap

Port proximity matching

Netlist source

Extracted from GDS via PDK rules

Derived from port co-location

Verification

Netlist vs. schematic comparison

Virtual connection count → 0

Parasitics

R, C, L

Cross-coupling, interference → DRC concern