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.
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? |
|
Are there unintended geometry overlaps? |
|
Notebook Stages¶
Setup — imports, PDK
Building blocks — spiral and coupler
Assembly — auto-detected physical connection
Netlist with warnings — dangling ports signal missing intent
Virtual connections — declaring schematic intent
Routing — replacing virtual connections with physical waveguides
Collision map — ustom COLLISION layer, visual overlap detection on custom COLLISION layer
Fixing intersections — spacing + route update
Final LVS check — clean netlist
Alternative:
component_from_netlistdeclarative 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]:
[3]:
coupler = pf.parametric.s_bend_coupler(coupling_length=2, s_bend_offset=2)
coupler
[3]:
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.
coupler1is placed only 2 µm belowcoupler0— intentionally 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]:
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]:
[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]:
[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]:
[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
RuntimeErrorif 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]:
Layers
| Name | Layer | Description | Color | Pattern |
|---|---|---|---|---|
| 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 | #ff4500cc | xx |
Extrusion Specs
| # | Mask | Limits (μm) | Sidewal (°) | Opt. Medium | Elec. Medium |
|---|---|---|---|---|---|
| 0 | 'WG_CORE' | 0, 0.22 | 0 | cSi_Li1993_293K | Medium(permittivity=12.3) |
| 1 | 'SLAB' | 0, 0.07 | 0 | cSi_Li1993_293K | Medium(permittivity=12.3) |
| 2 | 'METAL' | 1.72, 2.22 | 0 | Cu_JohnsonChristy1972 | PEC |
| 3 | 'TRENCH' | -inf, inf | 0 | Medium() | Medium() |
Ports
| Name | Classification | Description | Width (μm) | Limits (μm) | Radius (μm) | Modes | Target n_eff | Path profiles (μm) | Voltage path | Current path |
|---|---|---|---|---|---|---|---|---|---|---|
| CPW | electrical | CPW transmission line | 26.0552 | -9.05312, 12.9931 | 0 | 1 | 4 | '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) |
| Rib | optical | Rib waveguide | 2.16 | -1, 1.22 | 0 | 1 | 4 | 'WG_CORE': 0.4,…… 'SLAB': 2.4, 'WG_CLAD': 2.4 | ||
| Strip | optical | Strip waveguide | 2.25 | -1, 1.22 | 0 | 1 | 4 | 'WG_CORE': 0.5,…… 'WG_CLAD': 2.5 |
Background medium
- Optical: SiO2_Palik_Lossless
- Electrical: Medium(permittivity=4.2)
[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]:
8 · Fixing Intersections — Spacing + Route Update¶
PhotonForge uses parametric routes that recompute automatically when you call .update() after repositioning a reference. The fix is:
Increase the spacing between
coupler0andcoupler1Call
.update()on the affected route referencesRe-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]:
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 |
|---|---|
|
Sub-component references (can set |
|
Physical port snapping (auto-translate to touch) |
|
Auto-routed waveguide connections (replaces virtual + route calls) |
|
Externally exposed ports |
|
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]:
[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 |
|
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 |
|
Fix |
Increased spacing; called |
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 |