LiDAR Chip Layout and Verification

The component library left eight cells that each carry their own model. This notebook places them: a phased array that splits one input into 32 phase-shifted channels and fans them into 32 antennas, placed twice for transmit and receive, the coherent receiver between the two, the edge coupler at the facet, and the metal that takes 64 heaters and two photodiodes out to bond pads.
Two parts of the layout need design decisions.
The first is the fan-in. The phase shifters sit 25 um apart so that their heaters do not couple thermally, and the antennas sit 1.3 um apart to steer without grating lobes. Every channel therefore bends inward by a different amount over the same length, and the outer channels are longer. The technology page worked out what that costs when the laser is tuned. This notebook shows that the difference cannot be made up with straight waveguide, and records it instead.
The second is the metal. Sixty-four heater nets and two diodes leave a bank a few hundred micrometres tall and have to reach pad rows several millimetres long on a single metal layer, without one trace crossing another. The trace pattern is nested by construction. The notebook closes by checking the drawn nets against the intended ones, which is a layout-versus-schematic check.
The mask is written out at the end and checked against the design rules of the process.
# The cells, the technology and every measured number come fromfrom runpy import run_path
# Lidar_Components.qmd defines what this notebook builds on. Its shared cells are# generated into the module below; see docs/scripts/generate_notebook_modules.py.globals().update( {k: v for k, v in run_path("misc/lidar_components.py").items() if not k.startswith("_")})# Open the interactive layout viewer, which redraws as cells are built belowfrom photonforge.live_viewer import LiveViewer
viewer = LiveViewer()LiveViewer started at http://127.0.0.1:34703. Call its 'stop()' method to free the port.# Layout parameters. The phase-shifter pitch comes from the heater crosstalk measured on# the steering page: 3.5 percent at 20 um, so 25 um keeps the correction small.n_ch = 32 # Channels per arraypitch_ps = 25.0 # Phase-shifter pitch (um)pitch_ant = pitch # Antenna pitch (um), from the system budget: below the wavelengthl_fan = 600.0 # x budget of the fan-in from the shifter pitch to the antenna pitch (um)pad_size = 50.0 # Bond pad (um)pad_pitch = 90.0 # Pad centre to centre (um)bank_gap = 150.0 # Clear space between the two arrays (um)trace_pitch = 6.0 # Heater trace column and level pitch: 3 um metal, 3 um gap (um)lane_x0 = -250.0 # Leftmost heater-trace column, left of the tree root (um)escape = 30.0 # First trace level above and below a bank (um)
# Analytic waveguide model carrying the measured strip index and loss for every routeroute_model = pf.AnalyticWaveguideModel( n_eff=n_eff_strip, n_group=n_group_strip, propagation_loss=scattering_db_cm * 1e-4, # dB/cm to dB/um reference_frequency=f0,)# Give it the matching time stepper, which propagates through a real delay line.# The default fits poles to the S matrix, and that cannot hold a long delay.route_model.time_stepper = pf.AnalyticWaveguideTimeStepper( n_eff=n_eff_strip, n_group=n_group_strip, propagation_loss=scattering_db_cm * 1e-4,)# Apply it to every primitive the router builds routes fromfor primitive in ("bend", "s_bend", "straight", "route_s_bend"): pf.config.default_kwargs[primitive] = {"model": route_model}The phased array feed
Section titled “The phased array feed”One cell holds everything between the input waveguide and the antennas: a five-stage tree of 1x2 splitters, a bank of 32 phase shifters, and the fan-in. It is used twice, once per array.
The tree is balanced: every channel passes through five splitters and five S bends, and the S bends of one stage all have the same offset, so every channel reaches its phase shifter with the same path. The stage spacing is set from the offset each S bend has to make.
The phase shifters start on one line. Their heaters are then aligned, which the metal routing below needs, and the fan-in starts from one line too.
The fan-in is one S bend per channel over a common x budget, so the antennas also start on one line. Its arc length grows with the offset, and the offsets run from half a micrometre for the centre channels to nearly four hundred for the outer ones. A straight cannot make up that difference: any straight added to an inner channel takes x away from its S bend, and a shorter S bend for the same offset is longer still. Bending the inner channels outward first and back again does equalize the arcs, but only by giving every channel the same final offset, which puts every apex on the same line and the channels on top of each other. The equal-length alternatives all need tens of micrometres of room per channel, which a 25 um pitch does not have, so the mismatch is kept, measured with route_length, and stored on the cell for the steering page to correct with the heaters.
Every heater end becomes a terminal of the cell, H0_k on the signal side and H1_k on the
ground side, and the fan-in outputs become its ports A0 to A31. The cell carries a circuit
model, so the steering page can ask it for the amplitude and
phase that reach each antenna.
@pf.parametric_component(name_prefix="OPA")def phased_array(*, pitch=pitch_ps, fan_length=l_fan): # Empty name, so the decorator builds one from the arguments c = pf.Component()
# Length of one splitter cell, which sets the stage spacing with the S bends mmi_len = splitter.size()[0]
# Tree: stage j holds 2^j splitters at centre spacing pitch * 2^(5 - j) x = 0.0 stage_refs, links = [], [] for j in range(5): # Centre spacing of this stage, and the y of each splitter in it spacing = pitch * 2 ** (5 - j) ys = (np.arange(2**j) - (2**j - 1) / 2) * spacing
# Place the splitters of this stage on one line refs = [c.add_reference(splitter).translate((x, y)) for y in ys] stage_refs.append(refs)
# Link each splitter to its parent; even children hang off P1, odd children off P2 if j > 0: prev = stage_refs[j - 1] links.append( [ c.add_reference( pf.parametric.route_s_bend( port1=(prev[k // 2], "P2" if k % 2 else "P1"), port2=(ref, "P0"), ) ) for k, ref in enumerate(refs) ] )
# Advance to the next stage by the cell length plus room for the S bend offset offset = spacing / 4 - 1.0 if j < 4 else pitch / 2 - 1.0 x += mmi_len + max(20.0, 2.5 * offset)
# Every phase shifter starts on this line x_ps = x
# Channel positions at the shifter pitch y_ch = (np.arange(n_ch) - (n_ch - 1) / 2) * pitch
# Place one shifter per channel, link it to the last splitter stage, expose its heater ends final = [] for k in range(n_ch): ps_ref = c.add_reference(ps).translate((x_ps, y_ch[k])) final.append( c.add_reference( pf.parametric.route_s_bend( port1=(stage_refs[-1][k // 2], "P2" if k % 2 else "P1"), port2=(ps_ref, "P0"), ) ) ) c.add_terminal(ps_ref["H0"], f"H0_{k}") c.add_terminal(ps_ref["H1"], f"H1_{k}")
# Antenna positions, and the line the fan-in starts from y_ant = (np.arange(n_ch) - (n_ch - 1) / 2) * pitch_ant x_fan = x_ps + ps.size()[0]
# Fan-in: one S bend per channel over the common x budget, its far end an antenna port fans = [] for k in range(n_ch): fan = c.add_reference( pf.parametric.s_bend( port_spec="TE_1550_500", length=fan_length, offset=float(y_ant[k] - y_ch[k]), ) ).translate((x_fan, y_ch[k])) fans.append(fan) c.add_port(fan["P1"], f"A{k}")
# The tree root is the input of the whole cell c.add_port(stage_refs[0][0]["P0"], "IN")
# Check that neighbouring fans do not touch: two channels' silicon must merge into two polygons for k in range(n_ch - 1): pair = pf.Component() for r in (fans[k], fans[k + 1]): pair.add_reference(r.component).translate(r.origin) assert ( len(pf.boolean(pair.get_structures("Si"), [], "+")) == 2 ), f"fan-in channels {k} and {k + 1} touch"
# Measure the path from the input to each antenna: the links on the channel's branch of # the tree, its last S bend, the shifter and its fan path = np.zeros(n_ch) for k in range(n_ch): for j, stage in enumerate(links, start=1): path[k] += pf.route_length(stage[k // 2 ** (5 - j)].component) path[k] += ( pf.route_length(final[k].component) + ps.size()[0] + pf.route_length(fans[k].component) )
# Store the geometry the chip and the steering page read back for key, value in ( ("x_ps", x_ps), ("x_fan", x_fan), ("y_ch", y_ch.tolist()), ("path", path.tolist()), ): c.properties[key] = value
# A circuit model, so the feed answers for the amplitude and phase it delivers to each antenna c.add_model(pf.CircuitModel(), "Circuit") return c
# Build the feed cell and read the recorded paths backfeed = phased_array()path = np.array(feed.properties["path"])print( f"feed cell {feed.size()[0]:.0f} x {feed.size()[1]:.0f} um, {len(feed.references)} references, " f"{len(feed.ports)} ports, {len(feed.terminals)} terminals")print( f"path from input to antenna: {path.min():.2f} to {path.max():.2f} um, mismatch {path.max() - path.min():.1f} um")
# Phase the mismatch puts between the centre and the outer channel over the tuning rangetune = wavelengths[-1] - wavelengths[0] # Laser tuning range (um)drift = 2 * np.pi * n_group_strip * (path.max() - path.min()) * tune / lda0**2print( f"over the {tune * 1e3:.0f} nm tune the outer channel drifts {drift:.0f} rad against the centre one")viewer(feed)feed cell 1888 x 778 um, 157 references, 33 ports, 64 terminalspath from input to antenna: 1866.50 to 2006.54 um, mismatch 140.0 umover the 40 nm tune the outer channel drifts 61 rad against the centre oneThe mismatch is quadratic in the channel position, since an S bend’s excess length grows with the square of its offset. Across the tuning range it amounts to tens of radians between the centre and the edge of the array, which is why the heater settings on the steering page are a table against wavelength rather than one set of numbers. For the 2 GHz chirp of a single range measurement the same mismatch is a few hundredths of a radian, which is negligible.
The antenna array
Section titled “The antenna array”The two antenna cells designed on the antenna page are 20-period test sections with full FDTD models. The array needs the full-length versions, with the 500 nm and the 450 nm strip alternating down the array so that every antenna has a detuned neighbour on both sides. The full-length antennas cannot carry an FDTD model: no chip simulation can afford one, and the far field it would produce is not an S parameter. From the circuit’s point of view an antenna is a waveguide that loses its light on purpose, so each long antenna gets an AnalyticWaveguideModel with its measured grating index and its radiation strength entered as propagation loss. What leaves the far end is the 10 percent that the antenna length was chosen to leave. The steering page takes the direction of the radiated light from the patterns saved on the antenna page.
# The two antennas at full length, each with its own period, tooth and index from the antenna pagelong_antennas = {}for width, design in antenna_design.items(): cell = grating_antenna( period=design["period"], tooth=design["tooth"], duty=design["duty"], width=width, num_periods=int(round(l_antenna / design["period"])), overlap=0.01, ) # Radiation as a propagation loss: the guided power falls as exp(-2 alpha x). The length is # given explicitly, since the model's own path measurement stops at the narrower strip's tapers. loss_db_per_um = 2 * design["alpha"] * 10 / np.log(10) + scattering_db_cm * 1e-4 model = pf.AnalyticWaveguideModel( n_eff=design["n_avg"], n_group=n_group_strip, length=cell.size()[0], propagation_loss=loss_db_per_um, reference_frequency=f0, ) model.time_stepper = pf.AnalyticWaveguideTimeStepper( n_eff=design["n_avg"], n_group=n_group_strip, length=cell.size()[0], propagation_loss=loss_db_per_um, ) # The latest model added is the active one, so the FDTD model stays attached but unused cell.add_model(model, "radiating") long_antennas[width] = cellwidths = list(long_antennas) # 500 nm on even channels, 450 nm on odd ones
# Antenna positions at the antenna pitchantenna_array = pf.Component("antenna_array")y_ant = (np.arange(n_ch) - (n_ch - 1) / 2) * pitch_ant
# Place the 32 antennas, widths alternating, and expose their inputs and far ends as portsfor k in range(n_ch): ref = antenna_array.add_reference(long_antennas[widths[k % 2]]).translate( (0, y_ant[k]) ) antenna_array.add_port(ref["P0"], f"A{k}") antenna_array.add_port(ref["P1"], f"END{k}")
# What reaches the far end of each antenna, from the models just attachedfor width, cell in long_antennas.items(): s_ant = cell.s_matrix([f0], show_progress=False) print( f"{width * 1e3:.0f} nm antenna, {cell.size()[0]:.0f} um long: transmitted to the far end " f"{10 * np.log10(abs(s_ant[('P0@0', 'P1@0')][0]) ** 2):.1f} dB" )print(f"array {antenna_array.size()[0]:.0f} x {antenna_array.size()[1]:.1f} um")viewer(antenna_array)500 nm antenna, 1052 um long: transmitted to the far end -11.1 dB450 nm antenna, 1056 um long: transmitted to the far end -10.5 dBarray 1056 x 43.3 umThe chip
Section titled “The chip”The transmit array sits above the receive array, a copy of it translated down. The two are copies rather than mirror images because the antennas emit forward, ten degrees off normal in the direction of propagation, and both beams have to point the same way. The distance between the two apertures does not affect the link: the beam is 37 cm wide at ten metres, so a receiver a millimetre to the side sees the same return.
The input section runs along the left edge: the edge coupler at the facet, then the tap, then the transmit tree root. The tap arm is mirrored below the bus so the local oscillator can turn down towards the receiver at once; drawn above, it would have to cross the tree.
The receiver sits between the arrays: the 2x2 coupler rotated so its inputs face the receive tree root, with S bends fanning its four ports from 2 um to 20 um apart, because two waveguides cannot be routed to ports 2 um apart and two photodiodes cannot be placed on them.
The heater traces are the part that needs a rule. Every heater’s signal end runs left along its own channel, past the tree, to a column in a lane left of the tree root; up the column to a level above the bank; along the level to its pad; and into the pad row along the top edge. Channels farther from the pad row take the outer column, the outer level and the outer pad, so the traces nest and no two cross. The ground ends are shorter: each runs a few micrometres to a rail just past the heaters, and the rail leaves along the edge of the bank to the last pad of the row. The receive array is the same pattern reflected to the bottom row, and the four photodiode terminals take the four pads after the receive ground, escaping through the gap between the arrays.
Metal over a waveguide is not a problem here: the routing metal is 1.4 um above the silicon, and the mode’s evanescent tail has fallen by twenty orders of magnitude at that height. Metal over a heater is a problem, since it sinks the heat, and the design rules below check for it.
def centre(terminal): """Centre of a terminal (a method on Terminal, an attribute on Port).""" c = terminal.center return np.asarray(c() if callable(c) else c)
def assemble_chip(antennas=True, name="lidar_chip"): """Both arrays, the receiver, the facet, the pads and every trace. Returns the chip and the intended electrical netlist as (terminal, pad, net name) triples.
With antennas=False the fan-in outputs are exposed as ports instead, which is how the steering and ranging pages drive the feed networks. """ # Create the parent chip component; the intended nets are declared on it as the traces are drawn chip = pf.Component(name)
# Drop of the receive array: one bank height plus the gap between the arrays bank_h = feed.size()[1] y_rx = -(bank_h + bank_gap)
# Place the two feeds, the receive one translated down tx = chip.add_reference(feed) rx = chip.add_reference(feed).translate((0, y_rx))
# Butt an antenna array onto each feed, or expose the fan-in outputs as chip ports if antennas: tx_ant = chip.add_reference(antenna_array) rx_ant = chip.add_reference(antenna_array) tx_ant.connect("A0", tx["A0"]) rx_ant.connect("A0", rx["A0"]) else: for k in range(n_ch): chip.add_port(tx[f"A{k}"], f"TX{k}") chip.add_port(rx[f"A{k}"], f"RX{k}")
# Connect the tap's through port to the transmit tree root tap_ref = chip.add_reference(tap) tap_ref.connect("P2", tx["IN"])
# Mirror the tap about its bus if the arm came out above it, so the LO can turn down if tap_ref["P3"].center[1] > tap_ref["P2"].center[1]: tap_ref.mirror(tap_ref["P0"].center, tap_ref["P2"].center)
# Butt the edge coupler onto the tap's input; the facet and the unused arm end are chip ports facet = chip.add_reference(ec) facet.connect("P1", tap_ref["P0"]) chip.add_port(facet["P0"], "FIBER") chip.add_port(tap_ref["P1"], "TAP_ISO")
# Place the mixer between the arrays, rotated so its inputs face the receive tree root mixer_ref = chip.add_reference(mixer).rotate(180) mixer_ref.translate((-220.0 - mixer_ref.origin[0], y_rx / 2 + 60.0))
# Sort the mixer's inputs and outputs by height, so the routing below can name them in_upper, in_lower = sorted( ("P0", "P1"), key=lambda p: mixer_ref[p].center[1], reverse=True ) out_upper, out_lower = sorted( ("P2", "P3"), key=lambda p: mixer_ref[p].center[1], reverse=True )
def fanned(port_name, up): # S bend on one mixer port whose free end lies above (up) or below the port # Try both offset signs: connect() rotates the bend to meet the port and can flip the sign for sign in (+1, -1): sb = pf.Reference( pf.parametric.s_bend( port_spec="TE_1550_500", length=40.0, offset=9.0 * sign ) ) sb.connect("P1", mixer_ref[port_name]) if (sb["P0"].center[1] > mixer_ref[port_name].center[1]) == up: chip.add(sb) return sb
# Fan all four mixer ports from 2 um apart to 20 um apart fan = { name: fanned(name, up) for name, up in ( (in_upper, True), (in_lower, False), (out_upper, True), (out_lower, False), ) }
# Butt a photodiode onto each fanned output and expose its electrical port pd_upper = chip.add_reference(pd) pd_lower = chip.add_reference(pd) pd_upper.connect("P0", fan[out_upper]["P0"]) pd_lower.connect("P0", fan[out_lower]["P0"]) chip.add_port(pd_upper["E0"], "PD_UPPER") chip.add_port(pd_lower["E0"], "PD_LOWER")
# Route the receive root to the lower mixer input, left to the router: one bend up, one left chip.add_reference( pf.parametric.route(port1=rx["IN"], port2=fan[in_lower]["P0"], radius=20.0) )
# Route the local oscillator to the upper input through waypoints, since the arm points at # the tree. A waypoint is where a straight ends and its bend begins. # Puts the downward run 17 um clear of the first splitter x_lo = tap_ref["P3"].center[0] + 21.0 chip.add_reference( pf.parametric.route( port1=tap_ref["P3"], port2=fan[in_upper]["P0"], radius=20.0, waypoints=[ (x_lo, tap_ref["P3"].center[1]), (x_lo, fan[in_upper]["P0"].center[1]), ], ) )
# Read the shifter line and the channel positions back from the feed cell x_ps = feed.properties["x_ps"] y_ch = np.array(feed.properties["y_ch"])
# Pad rows: one above the transmit array, one below the receive array, past the trace levels pad_y = { +1: bank_h / 2 + escape + n_ch * trace_pitch + 80.0, -1: y_rx - bank_h / 2 - escape - n_ch * trace_pitch - 80.0, }
def heater_traces(array_ref, y0, side, label): # Signal traces to one pad row (side +1 above, -1 below) and the ground rail of one array for k in range(n_ch): # Rank of this channel from the far side of the bank; 0 is farthest from the pad row m = k if side > 0 else n_ch - 1 - k
# Farther channels take the outer column, the outer level and the outer pad x_col = lane_x0 + trace_pitch * m level = y0 + side * (bank_h / 2 + escape + trace_pitch * (n_ch - 1 - m)) x_pad = pad_pitch * m
# Place the pad and draw the trace: along the channel, up the column, along the level pad_ref = chip.add_reference(bond_pad).translate((x_pad, pad_y[side])) chip.add_reference( pf.parametric.route_manhattan( terminal1=(array_ref, f"H0_{k}"), terminal2=(pad_ref, "T0"), width=metal_width, waypoints=[(x_col, y0 + y_ch[k]), (x_col, level), (x_pad, level)], ) )
# Declare the net the trace was drawn for: the intent the LVS check compares against chip.add_virtual_net([(array_ref, f"H0_{k}"), (pad_ref, "T0")])
# Ground rail just past the heater ends, from the first channel to the bank edge x_rail = x_ps + ps.size()[0] + 12.0 y_edge = y0 + side * (bank_h / 2 + 10.0) chip.add( "M1", pf.Rectangle( (x_rail - metal_width / 2, min(y0 + y_ch[0], y_edge)), (x_rail + metal_width / 2, max(y0 + y_ch[-1], y_edge)), ), )
# Ground pad at the end of the row x_gnd = pad_pitch * n_ch gnd = chip.add_reference(bond_pad).translate((x_gnd, pad_y[side]))
# Stub from every ground end to the rail for k in range(n_ch): stub = pf.Terminal( "M1", pf.Rectangle( center=(x_rail, y0 + y_ch[k]), size=(metal_width, metal_width) ), ) chip.add_reference( pf.parametric.route_manhattan( terminal1=(array_ref, f"H1_{k}"), terminal2=stub, width=metal_width ) ) # One declared net for the whole ground: every heater's ground end and the ground pad chip.add_virtual_net( [(array_ref, f"H1_{k}") for k in range(n_ch)] + [(gnd, "T0")] )
# Rail out along the bank edge and up into the ground pad chip.add( "M1", pf.Path((x_rail, y_edge), metal_width) .segment((x_gnd, y_edge)) .segment((x_gnd, pad_y[side])), )
# Trace both arrays, the transmit one to the top row and the receive one to the bottom row heater_traces(tx, 0.0, +1, "TX") heater_traces(rx, y_rx, -1, "RX")
def by_height(ref): # The diode's two terminals, highest first return sorted( ("ANODE", "CATHODE"), key=lambda t: centre(ref[t])[1], reverse=True )
# Escape of each diode terminal away from the other diode, outer ones by 20 um and inner # ones by 6 um, listed from the highest escape to the lowest top_u, bot_u = by_height(pd_upper) top_l, bot_l = by_height(pd_lower) escapes = [ (pd_upper, top_u, +20.0), (pd_upper, bot_u, -6.0), (pd_lower, top_l, +6.0), (pd_lower, bot_l, -20.0), ]
# Columns left of both diodes, levels in the gap between the arrays x_pd_left = min(pd_upper.bounds()[0][0], pd_lower.bounds()[0][0]) y_gap = y_rx / 2 - 20.0
# Trace each terminal: escape, left to its column, down to its level, right to its pad. # Highest escape takes the leftmost column, the deepest level and the leftmost pad. for i, (ref, term, dy) in enumerate(escapes): x_col = x_pd_left - 15.0 - trace_pitch * (len(escapes) - 1 - i) level = y_gap + 8.0 * i x_pad = pad_pitch * (n_ch + 1 + i) pad_ref = chip.add_reference(bond_pad).translate((x_pad, pad_y[-1])) t = centre(ref[term]) chip.add_reference( pf.parametric.route_manhattan( terminal1=(ref, term), terminal2=(pad_ref, "T0"), width=metal_width, waypoints=[ (t[0], t[1] + dy), (x_col, t[1] + dy), (x_col, level), (x_pad, level), ], ) ) chip.add_virtual_net([(ref, term), (pad_ref, "T0")])
# Floor plan around everything drawn, and the dicing street outside it # Clear oxide inside the chip edge, and the width the saw needs (um) margin, street = 50.0, 60.0 (x0, y0), (x1, y1) = chip.bounds() chip.add( "FloorPlan", pf.Rectangle((x0 - margin, y0 - margin), (x1 + margin, y1 + margin)), ) chip.add( "Dicing", *pf.boolean( pf.Rectangle( (x0 - margin - street, y0 - margin - street), (x1 + margin + street, y1 + margin + street), ), pf.Rectangle((x0 - margin, y0 - margin), (x1 + margin, y1 + margin)), "-", ), )
# A circuit model, so the chip answers s_matrix from the models its cells carry chip.add_model(pf.CircuitModel(), "Circuit") return chip
# Build the chip with its antennas, which is what the mask and the checks below usechip = assemble_chip()
# Count what was placedpads = [r for r in chip.references if r.component is bond_pad]routes = [ r for r in chip.references if "route" in str(r.component.parametric_function or "")]print( f"chip {chip.size()[0]:.0f} x {chip.size()[1]:.0f} um = {chip.size()[0] * chip.size()[1] * 1e-6:.2f} mm^2")print( f"{len(chip.references)} references at the top level, {len(pads)} pads, {len(routes)} routes, " f"ports {list(chip.ports)}")viewer(chip)chip 3848 x 2580 um = 9.93 mm^2217 references at the top level, 70 pads, 134 routes, ports ['FIBER', 'PD_LOWER', 'PD_UPPER', 'TAP_ISO']Verification
Section titled “Verification”Three checks, from cheapest to most thorough, before the mask is written.
Every port that should be connected is connected. A route that failed to build is a warning, not an error, so the count of reference ports left open is checked against the ones that are open by design: the far ends of the 64 antennas, where the last tenth of the light leaves. The tap’s unused port and the facet are open as well, but they were made ports of the chip, so they are not counted.
No waveguide crosses another. The tree and the fan-in are checked inside the feed cell as it is built. The two routes drawn at chip level, the local oscillator and the receive root, are checked here against everything else on the chip with routing_collisions, on the silicon layer only: the heater traces cross these waveguides deliberately, on metal 1.4 um above them.
# Reference ports left open, against the ones open by designloose = chip.disconnected_reference_ports()print( f"open reference ports: {len(loose)}, expected {2 * n_ch}: the antenna far ends. " f"The tap's isolated port and the facet are open too, but they are ports of the chip.")
# The two waveguide routes drawn at chip level, the metal routes excludedoptical = [r for r in routes if "manhattan" not in str(r.component.parametric_function)]
# Check each route against everything else on the chip, the other route included. Silicon# only: metal traces cross these waveguides on purpose, 1.4 um above them.for label, r in zip(("receive root to mixer", "local oscillator to mixer"), optical): obstacles = [ o.transformed_component(f"obstacle_{i}") for i, o in enumerate(chip.references) if o is not r ] hits = pf.routing_collisions(r.component, obstacles, collision_layers=["Si"]) print(f"{label}: {sum(len(h) for h in hits)} silicon collisions")open reference ports: 128, expected 64: the antenna far ends. The tap's isolated port and the facet are open too, but they are ports of the chip.receive root to mixer: 0 silicon collisionslocal oscillator to mixer: 0 silicon collisionsDesign rule check
Section titled “Design rule check”A layout view shows the arrangement. It does not show whether the mask can be fabricated. That question belongs to a design rule check, which measures every drawn feature against what the process holds.
PhotonForge runs one through KLayout. The rules live in a separate runset, lidar.drc, written in KLayout’s own syntax and following the SiEPIC EBeam deck that ships with these notebooks, with this technology’s numbers: 150 nm silicon lines and gaps, micron-scale metal, contacts on silicon and on germanium, vias on the heater and under metal. Two rules are specific to this chip. The antenna teeth are 100 nm features on the partial-etch layer, and the deck holds a width and a spacing rule for that layer at 100 nm, the minimum the antenna page assumed. Metal over a heater anywhere but on its via is flagged, because half a micrometre of oxide is all that separates the two, and a trace there would sink the heat that the heater is meant to put into the waveguide.
This needs the KLayout application installed; PhotonForge finds it and runs it.
# The check exports the die to GDS itself, runs the runset on it and reads the markers backresults = pf.drc_check(chip, "misc/lidar.drc", show_progress=False)print(f"DRC clean: {results.is_clean}")print(results)DRC clean: TrueDRC results summary--------------------------------Total violations: 0
Violations by category:Boundary: 0Boundary_M1: 0Si_width: 0Si_space: 0SiPartial_width: 0SiPartial_space: 0Ge_width: 0Ge_space: 0Heater_width: 0Heater_space: 0M1_width: 0M1_space: 0Contact_width: 0GeContact_width: 0Via_width: 0Via_space: 0PadOpen_width: 0PadOpen_space: 0M1_PadOpen_enclosure: 0Contact_on_Si: 0GeContact_on_Ge: 0Via_on_heater: 0Via_in_M1: 0M1_over_heater: 0The deck comes back clean. The first runs of this layout failed in three places, each a mistake that a design rule check exists to catch.
- The local oscillator route left the tap arm on the wrong side of the bus and had to turn down through the first splitter. Thousands of spacing violations from one waypoint.
- The receive-root route and the local-oscillator route both reached the mixer’s inputs, which are 2 um apart, and ran parallel for 130 um with their edges touching. Hence the S bends that now fan the mixer’s ports out to 20 um.
- The two photodiodes were butted straight onto those 2 um outputs and overlapped each other.
Layout versus schematic: the electrical nets
Section titled “Layout versus schematic: the electrical nets”A design rule check says the metal can be made. It does not say the metal connects what it should.
That is the job of a layout-versus-schematic check. The schematic is the intent declared while the
traces were drawn: each add_virtual_net call in the assembly names the terminals one trace was
meant to join, a heater end and its pad, the 32 ground ends with the ground pad of their array,
and each photodiode terminal with its pad. PhotonForge writes that intent as a netlist, exports the
layout with the terminal names as text labels on the metal, and KLayout extracts the nets the
metal actually forms, following the runset lidar.lvs: the routing metal, the vias and
the contact plugs conduct; the heater film and the semiconductors do not, so a heater is two
separate pins and a trace bridging its ends would merge two declared nets into one. The two
netlists are then compared cell by cell. The check is electrical: the copies written out have
their optical ports removed, so neither netlist carries the waveguide side, which the circuit
model covers.
def label_terminals(component): """Applied to the copy of every cell written out for the check: each terminal name becomes a text label on the metal, so the extracted nets carry the names of the pins they reach. The optical ports go at the same time, so the count below is the electrical side only. """ for name, terminal in component.terminals.items(): component.add("M1", pf.Label(name, terminal.center())) for name in list(component.ports): component.remove_port(name)
results = pf.lvs_check( chip, "misc/lidar.lvs", pre_export_function=label_terminals, show_progress=False)top = results.comparison(chip.name)print(f"LVS verdict: {results.verdict}")print( f"{len(top.schematic.nets)} nets declared, {len(top.layout.nets)} extracted from the metal; " f"{sum(c.status == 'match' for c in results.comparisons)} of {len(results.comparisons)} cells match")for message in results.messages: if message.severity == "error": print(message.text)LVS verdict: match_with_warnings70 nets declared, 70 extracted from the metal; 5 of 5 cells matchThe verdict is a match with warnings: the 70 declared nets are the 70 nets the metal forms, every cell’s pins land where its subcircuit says, and no heater is bridged. The warnings say that the 64 signal nets have the same shape, a pad at one end and a heater end at the other, so KLayout matched them as a group; the assignment follows the heater pin names, and a metal bridge between two pads turns the verdict into a mismatch that names the merged net. A first version of the runset connected the heater film like the metal, and every signal came out shorted to ground. The check was right: a heater does connect its two ends, resistively. Deciding which layers are wiring and which are devices is the first line of any LVS deck.
What comes next
Section titled “What comes next”The chip is drawn, wired and checked, and every cell carries a model. The beam steering page asks the feed network for the amplitude and phase it delivers to each of the 32 antennas, combines them with the element pattern from the antenna page into the beam the array forms, and works out the heater settings that point it, including the correction for the path lengths recorded above.