#!/usr/bin/env python3 """Device v3 fiber-collection chip: GDS layout generator. Builds `chip.gds` (one 10 mm x 10 mm mother chip carrying 8 parallel Si3N4/SiO2 waveguide devices, mirrored about the mid-chip cleave line so that cleaving the chip in half yields two independent 5 mm devices) and `wafer.gds` (a 10 x 10 grid of `chip.gds` tiled across a 101.6 mm / 4-inch wafer, matching the tiling convention used in `crystal_deposition/hex_dot_wafer_boy_v1/generate_gds.py`). Also renders `figures/schematic.png`, a flat-color top-view of one chip with one legend color per GDS layer, in the spirit of `tools/render_gds_schematics.py` but layer-aware (that tool's monochrome single-cell renderer isn't informative for a design with this many functional layers). Run with the repo's venv, which has gdstk + matplotlib installed: .venv/bin/python nanophotonic_devices/fiber_collection_optics/v3_splitter_and_lens/build_v3_gds.py See README.md in this directory for the full parameter rationale and the GDS layer table. """ from __future__ import annotations import math from pathlib import Path import gdstk import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.collections import PolyCollection from matplotlib.patches import Patch HERE = Path(__file__).resolve().parent # -------------------------------------------------------------------------- # GDS layer table -- (layer, datatype): (name, purpose, render color) # -------------------------------------------------------------------------- L_WG_CORE = (1, 0) L_KOH_WINDOW = (2, 0) # Si3N4+SiO2 mask opening -> bare Si, fiber v-grooves L_CLEAVE_VGROOVE = (2, 1) # same KOH etch step, cleave-guide stress-riser notches L_SIN_CLAD_OPEN = (7, 0) # dielectric-stack removal mask (biased KOH_WINDOW/CLEAVE_VGROOVE) L_ELECTRODE_METAL = (3, 0) # in-plane Stark electrodes flanking waveguide at crystal end L_ELECTRODE_ROUTING = (3, 1) # routing traces + bond pads, same metal, sibling datatype L_ALIGN_FAB = (4, 0) # wafer/stepper fab alignment (corner crosses) L_ALIGN_2PP = (5, 0) # fine alignment marks for 2PP lens printer, fiber-coupling region L_CHIP_OUTLINE = (6, 0) # chip boundary / dicing reference (not a fab mask layer) L_WAFER_OUTLINE = (10, 0) # wafer.gds only: outline ring + primary flat L_WAFER_ALIGN = (11, 0) # wafer.gds only: wafer-level (not per-chip) alignment marks LAYER_TABLE = [ (L_WG_CORE, "WG_CORE", "Si3N4 waveguide core (splitter + branches)"), (L_KOH_WINDOW, "KOH_WINDOW", "SiN+SiO2 opened to bare Si -- fiber-seating v-groove etch mask"), (L_CLEAVE_VGROOVE, "CLEAVE_VGROOVE", "Same KOH step, cleave-initiation stress-riser notches"), (L_SIN_CLAD_OPEN, "SIN_CLAD_OPEN", "Dielectric (Si3N4+SiO2) removal mask, biased vs. KOH_WINDOW"), (L_ELECTRODE_METAL, "ELECTRODE_METAL", "In-plane Stark electrodes flanking waveguide, +/-y"), (L_ELECTRODE_ROUTING, "ELECTRODE_ROUTING", "Electrode routing traces + bond pads"), (L_ALIGN_FAB, "ALIGN_FAB", "General wafer/stepper fab alignment marks (chip corners)"), (L_ALIGN_2PP, "ALIGN_2PP", "2PP printer alignment marks, near fiber-coupling region"), (L_CHIP_OUTLINE, "CHIP_OUTLINE", "Chip boundary reference (not a mask layer)"), (L_WAFER_OUTLINE, "WAFER_OUTLINE", "Wafer edge ring + primary flat (wafer.gds only)"), (L_WAFER_ALIGN, "WAFER_ALIGN", "Wafer-level alignment marks (wafer.gds only)"), ] LAYER_COLORS = { L_WG_CORE: "#2a6fdb", L_KOH_WINDOW: "#e0a12a", L_CLEAVE_VGROOVE: "#c0392b", L_SIN_CLAD_OPEN: "#f4d58d", L_ELECTRODE_METAL: "#8f2fc4", L_ELECTRODE_ROUTING: "#c17fe8", L_ALIGN_FAB: "#2ca02c", L_ALIGN_2PP: "#17becf", L_CHIP_OUTLINE: "#888888", L_WAFER_OUTLINE: "#444444", L_WAFER_ALIGN: "#2ca02c", } # -------------------------------------------------------------------------- # Chip parameters (all lengths in micrometers -- GDS unit=1e-6, precision=1e-9) # -------------------------------------------------------------------------- CHIP_SIZE = 10_000.0 # 10 mm x 10 mm chip HALF_CHIP = CHIP_SIZE / 2 N_WG = 8 # waveguide count (within the 5-10 spec range) WG_PITCH = 700.0 # row-to-row pitch, see README for derivation WG_WIDTH = 0.4 # matches splitter_fdtd/splitter_sim.py's simulated # single-mode 0.4 um Si3N4 strip (300 nm thick, 780 nm) PROP_LEN_TO_SPLITTER = 500.0 # facet -> Y-junction start TAPER_LEN = 100.0 # Y-junction adiabatic taper length (chip-scale schematic; # splitter_fdtd/splitter_sim.py verifies the local mode behavior # over a much shorter FDTD unit cell at the same width targets) JUNCTION_GAP = 0.1 # gap at the Y-junction split point: 2*WG_WIDTH+GAP = 0.9 um # stem width, matching splitter_fdtd/splitter_sim.py exactly BEND_LEN = 400.0 # S-bend length to reach full branch separation BRANCH_OFFSET = 90.0 # each branch's final offset from row centerline (+/-) VGROOVE_LEN = 2000.0 # v-groove trench length along the fiber/waveguide axis KOH_WINDOW_WIDTH = 160.0 # KOH mask-opening width (self-limiting V, see README) SIN_CLAD_BIAS = 5.0 # SIN_CLAD_OPEN oversize vs. KOH_WINDOW, each side EDGE_X = HALF_CHIP # outer (fiber-side) die edge, pre-mirror local coordinate FACET2_X = EDGE_X - VGROOVE_LEN # waveguide-to-fiber facet location (x=3000, local) SPLITTER_X = PROP_LEN_TO_SPLITTER # x=500 TAPER_END_X = SPLITTER_X + TAPER_LEN # x=600 BEND_END_X = TAPER_END_X + BEND_LEN # x=1000 ELECTRODE_LEN = 80.0 # electrode metal length along x, from the facet ELECTRODE_GAP = 2.0 # clearance from waveguide edge to electrode inner edge ELECTRODE_WIDTH = 5.0 # electrode metal width (y extent) # Electrode routing stays entirely inside its own waveguide lane and runs # parallel to the branches out to the outer (fiber-side) edge -- see the # note above build_row() for why this single-metal-layer topology was # chosen over fanning all rows out to the top/bottom chip edge. ELECTRODE_ROUTE_OFFSET = 240.0 # routing-corridor offset from row centerline, +/- ROUTE_TRACE_WIDTH = 10.0 ROUTE_PAD_SIZE = 80.0 ROUTE_PAD_SETBACK = 150.0 # bond pad distance in from the outer die edge CORNER_CROSS_ARM = 200.0 CORNER_CROSS_WIDTH = 12.0 CORNER_INSET = 300.0 ALIGN_2PP_ARM = 40.0 ALIGN_2PP_WIDTH = 4.0 CLEAVE_NOTCH_WIDTH = 200.0 # cleave-guide notch width at x=0, along x CLEAVE_NOTCH_DEPTH = 300.0 # how far the notch cuts in from the chip edge (y) WAFER_DIAMETER = 101_600.0 # 101.6 mm / 4-inch, matches hex_dot_wafer_boy_v1 WAFER_RADIUS = WAFER_DIAMETER / 2 WAFER_GRID = 10 # 10 x 10 grid of 10 mm chips, matches hex_dot_wafer_boy_v1 WAFER_OUTLINE_WIDTH = 500.0 WAFER_FLAT_LENGTH = 32_500.0 # SEMI M1.15 primary flat length for a 100 mm <100> wafer ROW_Y = [(-((N_WG - 1) / 2) + i) * WG_PITCH for i in range(N_WG)] def sine_sbend_points(x0, x1, y0, y1, n=30): pts = [] for k in range(n + 1): t = k / n x = x0 + (x1 - x0) * t y = y0 + (y1 - y0) * 0.5 * (1 - math.cos(math.pi * t)) pts.append((x, y)) return pts def corner_cross(x, y, arm=CORNER_CROSS_ARM, width=CORNER_CROSS_WIDTH, layer=L_ALIGN_FAB): r1 = gdstk.rectangle((x - arm / 2, y - width / 2), (x + arm / 2, y + width / 2), layer=layer[0], datatype=layer[1]) r2 = gdstk.rectangle((x - width / 2, y - arm / 2), (x + width / 2, y + arm / 2), layer=layer[0], datatype=layer[1]) return gdstk.boolean(r1, r2, "or", layer=layer[0], datatype=layer[1]) def vernier_cross(x, y, arm, width, layer): return corner_cross(x, y, arm=arm, width=width, layer=layer) def biased_rect(x0, y0, x1, y1, bias, layer): return gdstk.rectangle((x0 - bias, y0 - bias), (x1 + bias, y1 + bias), layer=layer[0], datatype=layer[1]) def build_row(row_index: int, row_y: float, sign: int): """Return the list of gdstk elements for one waveguide device (one half of one mirrored row): waveguide, splitter, both branches, both KOH v-grooves + dielectric-open masks, Stark electrodes + routing + pads, and the fiber-coupling-region 2PP alignment marks. """ elems = [] def X(x): return sign * x # ---- waveguide core: straight facet->splitter, Y-junction taper, two S-bend+straight branches straight = gdstk.FlexPath( [(X(0.0), row_y), (X(SPLITTER_X), row_y)], WG_WIDTH, layer=L_WG_CORE[0], datatype=L_WG_CORE[1], ) elems.append(straight) half_w_start = WG_WIDTH / 2 half_w_end = WG_WIDTH + JUNCTION_GAP / 2 taper_pts = [ (X(SPLITTER_X), row_y - half_w_start), (X(SPLITTER_X), row_y + half_w_start), (X(TAPER_END_X), row_y + half_w_end), (X(TAPER_END_X), row_y - half_w_end), ] taper = gdstk.Polygon(taper_pts, layer=L_WG_CORE[0], datatype=L_WG_CORE[1]) elems.append(taper) branch_start_offset = JUNCTION_GAP / 2 + WG_WIDTH / 2 for bsign in (+1, -1): y0 = bsign * branch_start_offset y1 = bsign * BRANCH_OFFSET pts = [(X(TAPER_END_X + dx), row_y + dy) for dx, dy in sine_sbend_points(0.0, BEND_LEN, y0, y1)] pts.append((X(FACET2_X), row_y + y1)) branch = gdstk.FlexPath(pts, WG_WIDTH, layer=L_WG_CORE[0], datatype=L_WG_CORE[1]) elems.append(branch) # KOH v-groove window + biased dielectric-open mask for this branch's fiber gy0 = row_y + y1 - KOH_WINDOW_WIDTH / 2 gy1 = row_y + y1 + KOH_WINDOW_WIDTH / 2 gx0, gx1 = sorted((X(FACET2_X), X(EDGE_X))) koh = gdstk.rectangle((gx0, gy0), (gx1, gy1), layer=L_KOH_WINDOW[0], datatype=L_KOH_WINDOW[1]) elems.append(koh) sin_open = biased_rect(gx0, gy0, gx1, gy1, SIN_CLAD_BIAS, L_SIN_CLAD_OPEN) elems.append(sin_open) # ---- Stark electrodes flanking the waveguide near the crystal-stamp facet. # Routing stays inside this row's own lane the whole way to a bond pad # near the outer (fiber-side) edge: a short local jog from the electrode # out to a corridor offset well clear of this row's own branches and KOH # window (offset 240 um, vs. the window's outer edge at 90+80=170 um), # then straight in +x, parallel to this row's own v-groove, to the pad. # Because every row's routing never leaves its own +/-350 um half-lane, # no crossing with another row's waveguide, electrode, or routing is # possible -- unlike a fan-out to a shared top/bottom-edge bus, which # would require crossing over neighboring lanes on a second metal layer. ex0, ex1 = sorted((X(0.0), X(ELECTRODE_LEN))) for esign in (+1, -1): ey_in = row_y + esign * (WG_WIDTH / 2 + ELECTRODE_GAP) ey_out = row_y + esign * (WG_WIDTH / 2 + ELECTRODE_GAP + ELECTRODE_WIDTH) ey0, ey1 = sorted((ey_in, ey_out)) elec = gdstk.rectangle((ex0, ey0), (ex1, ey1), layer=L_ELECTRODE_METAL[0], datatype=L_ELECTRODE_METAL[1]) elems.append(elec) corridor_y = row_y + esign * ELECTRODE_ROUTE_OFFSET pad_x = EDGE_X - ROUTE_PAD_SETBACK route_pts = [ (X(ELECTRODE_LEN), ey_out if esign > 0 else ey0), (X(ELECTRODE_LEN + 150.0), corridor_y), (X(pad_x), corridor_y), ] route = gdstk.FlexPath(route_pts, ROUTE_TRACE_WIDTH, layer=L_ELECTRODE_ROUTING[0], datatype=L_ELECTRODE_ROUTING[1]) elems.append(route) pad = gdstk.rectangle( (X(pad_x) - ROUTE_PAD_SIZE / 2, corridor_y - ROUTE_PAD_SIZE / 2), (X(pad_x) + ROUTE_PAD_SIZE / 2, corridor_y + ROUTE_PAD_SIZE / 2), layer=L_ELECTRODE_ROUTING[0], datatype=L_ELECTRODE_ROUTING[1], ) elems.append(pad) return elems def build_chip_cell(lib: gdstk.Library, name: str = "V3_CHIP") -> gdstk.Cell: cell = lib.new_cell(name) for i, row_y in enumerate(ROW_Y): for sign in (+1, -1): for el in build_row(i, row_y, sign): cell.add(el) # chip outline (reference only, not a mask layer) -- thin frame, not filled outer_frame = gdstk.rectangle((-HALF_CHIP, -HALF_CHIP), (HALF_CHIP, HALF_CHIP), layer=L_CHIP_OUTLINE[0], datatype=L_CHIP_OUTLINE[1]) inner_frame = gdstk.rectangle((-HALF_CHIP + 20, -HALF_CHIP + 20), (HALF_CHIP - 20, HALF_CHIP - 20), layer=L_CHIP_OUTLINE[0], datatype=L_CHIP_OUTLINE[1]) frame = gdstk.boolean(outer_frame, inner_frame, "not", layer=L_CHIP_OUTLINE[0], datatype=L_CHIP_OUTLINE[1]) for poly in frame: cell.add(poly) # 4 corner fab-alignment crosses for cx in (-HALF_CHIP + CORNER_INSET, HALF_CHIP - CORNER_INSET): for cy in (-HALF_CHIP + CORNER_INSET, HALF_CHIP - CORNER_INSET): cell.add(*corner_cross(cx, cy)) # cleave-guide notches at x=0, top and bottom edges (stress risers for the # mid-chip cleave that exposes the crystal-stamp facets) for yedge in (HALF_CHIP, -HALF_CHIP): y0, y1 = sorted((yedge, yedge - math.copysign(CLEAVE_NOTCH_DEPTH, yedge))) notch = gdstk.rectangle((-CLEAVE_NOTCH_WIDTH / 2, y0), (CLEAVE_NOTCH_WIDTH / 2, y1), layer=L_CLEAVE_VGROOVE[0], datatype=L_CLEAVE_VGROOVE[1]) cell.add(notch) cell.add(biased_rect(-CLEAVE_NOTCH_WIDTH / 2, y0, CLEAVE_NOTCH_WIDTH / 2, y1, SIN_CLAD_BIAS, L_SIN_CLAD_OPEN)) # 2PP alignment marks near the fiber-coupling region: one pair near the # waveguide->fiber facet, one pair near the fiber tips, per chip half max_row = max(ROW_Y) mark_y = max_row + KOH_WINDOW_WIDTH / 2 + 100.0 for sign in (+1, -1): for x_local in (FACET2_X, EDGE_X - 50.0): for ysign in (+1, -1): cell.add(*vernier_cross(sign * x_local, ysign * mark_y, ALIGN_2PP_ARM, ALIGN_2PP_WIDTH, L_ALIGN_2PP)) return cell def chip_bounds(ix: int, iy: int): x0 = (ix - WAFER_GRID / 2) * CHIP_SIZE y0 = (iy - WAFER_GRID / 2) * CHIP_SIZE return x0, y0, x0 + CHIP_SIZE, y0 + CHIP_SIZE def build_wafer(chip_cell: gdstk.Cell, lib: gdstk.Library) -> gdstk.Cell: top = lib.new_cell("V3_WAFER") n_placed = 0 for ix in range(WAFER_GRID): for iy in range(WAFER_GRID): x0, y0, x1, y1 = chip_bounds(ix, iy) corners = [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] if all(math.hypot(cx, cy) < WAFER_RADIUS - 500.0 for cx, cy in corners): cx = (x0 + x1) / 2 cy = (y0 + y1) / 2 top.add(gdstk.Reference(chip_cell, origin=(cx, cy))) n_placed += 1 # wafer outline ring with primary flat (SEMI M1.15-style, bottom orientation) outer = gdstk.ellipse((0, 0), WAFER_RADIUS, layer=L_WAFER_OUTLINE[0], datatype=L_WAFER_OUTLINE[1]) inner = gdstk.ellipse((0, 0), WAFER_RADIUS - WAFER_OUTLINE_WIDTH, layer=L_WAFER_OUTLINE[0], datatype=L_WAFER_OUTLINE[1]) ring = gdstk.boolean(outer, inner, "not", layer=L_WAFER_OUTLINE[0], datatype=L_WAFER_OUTLINE[1]) half_flat = WAFER_FLAT_LENGTH / 2 flat_depth = WAFER_RADIUS - math.sqrt(WAFER_RADIUS ** 2 - half_flat ** 2) flat_cut = gdstk.rectangle((-half_flat - 5000, -WAFER_RADIUS - 5000), (half_flat + 5000, -WAFER_RADIUS + flat_depth), layer=L_WAFER_OUTLINE[0], datatype=L_WAFER_OUTLINE[1]) ring = gdstk.boolean(ring, flat_cut, "not", layer=L_WAFER_OUTLINE[0], datatype=L_WAFER_OUTLINE[1]) for poly in ring: top.add(poly) # wafer-level alignment marks, just inside the flat edge, distinct from per-chip ALIGN_FAB align_y = -WAFER_RADIUS + flat_depth + 1500.0 for wx in (-20_000.0, 20_000.0): top.add(*vernier_cross(wx, align_y, 600.0, 30.0, L_WAFER_ALIGN)) print(f"wafer.gds: placed {n_placed} chips on a {WAFER_DIAMETER/1000:.1f} mm wafer " f"({WAFER_GRID}x{WAFER_GRID} grid)") return top # background-to-foreground draw order so small/critical features aren't # hidden under larger ones (e.g. the cleave notch under its own biased mask) RENDER_ORDER = [ L_SIN_CLAD_OPEN, L_KOH_WINDOW, L_CLEAVE_VGROOVE, L_WG_CORE, L_ELECTRODE_ROUTING, L_ELECTRODE_METAL, L_CHIP_OUTLINE, L_ALIGN_FAB, L_ALIGN_2PP, ] LAYER_NAMES = {layer: name for layer, name, _ in LAYER_TABLE} def _draw_layers(ax, chip_cell, xlim, ylim, min_linewidth=0.0): handles = [] for layer in RENDER_ORDER: polys = chip_cell.get_polygons(layer=layer[0], datatype=layer[1]) if not polys: continue color = LAYER_COLORS[layer] verts = [p.points for p in polys] pc = PolyCollection(verts, facecolor=color, edgecolor=color, linewidths=min_linewidth, alpha=0.9) ax.add_collection(pc) handles.append(Patch(facecolor=color, label=LAYER_NAMES[layer])) ax.set_xlim(*xlim) ax.set_ylim(*ylim) ax.set_aspect("equal") ax.set_axis_off() return handles def render_schematic(chip_cell: gdstk.Cell, out_path: Path): mid_row_y = ROW_Y[len(ROW_Y) // 2] fig, (ax_full, ax_row, ax_facet) = plt.subplots(1, 3, figsize=(16, 6), dpi=150, gridspec_kw={"width_ratios": [1.1, 1.6, 1.0]}) handles = _draw_layers(ax_full, chip_cell, (-HALF_CHIP * 1.02, HALF_CHIP * 1.02), (-HALF_CHIP * 1.02, HALF_CHIP * 1.02)) ax_full.axvline(0, color="black", linewidth=0.8, linestyle="--", alpha=0.6) ax_full.set_title("Full chip (10x10 mm)\ndashed line = mid-chip cleave", fontsize=9) _draw_layers(ax_row, chip_cell, (-50, EDGE_X + 50), (mid_row_y - 250, mid_row_y + 250), min_linewidth=0.3) ax_row.axvline(0, color="black", linewidth=0.8, linestyle="--", alpha=0.6) ax_row.set_title(f"One device, row y={mid_row_y:.0f} um\n" "facet -> Y-splitter -> S-bend branches -> v-grooves", fontsize=9) _draw_layers(ax_facet, chip_cell, (-130, 260), (mid_row_y - 40, mid_row_y + 40), min_linewidth=0.5) ax_facet.axvline(0, color="black", linewidth=0.8, linestyle="--", alpha=0.6) ax_facet.set_title("Crystal-stamp facet detail\nwaveguide + Stark electrodes", fontsize=9) fig.suptitle("Device v3 fiber-collection chip -- GDS layer preview", fontsize=12, y=1.03) fig.legend(handles=handles, loc="lower center", bbox_to_anchor=(0.5, -0.06), ncol=5, fontsize=8, frameon=False) fig.tight_layout() fig.savefig(out_path, facecolor="white", bbox_inches="tight") plt.close(fig) print(f"wrote {out_path}") def main(): chip_lib = gdstk.Library(unit=1e-6, precision=1e-9) chip_cell = build_chip_cell(chip_lib, "V3_CHIP") chip_gds = HERE / "chip.gds" chip_lib.write_gds(str(chip_gds)) print(f"wrote {chip_gds} ({len(chip_cell.polygons) + len(chip_cell.paths)} elements)") wafer_lib = gdstk.Library(unit=1e-6, precision=1e-9) wafer_chip_cell = build_chip_cell(wafer_lib, "V3_CHIP") build_wafer(wafer_chip_cell, wafer_lib) wafer_gds = HERE / "wafer.gds" wafer_lib.write_gds(str(wafer_gds)) print(f"wrote {wafer_gds}") figures_dir = HERE / "figures" figures_dir.mkdir(exist_ok=True) render_schematic(chip_cell, figures_dir / "schematic.png") if __name__ == "__main__": main()