Wavelength Plan and Thermal Budget

The chip the series arrives at, with the part this notebook works on marked:

The rings in Chip Layout are drawn to sit on eight channels. Fabrication moves them off, and each ring carries a heater to move it back. This notebook measures how far the rings move, how far a heater can move them, and what that costs in power.
Three quantities are measured:
- Resonance sensitivity to geometry. A resonance is the round-trip length divided into wavelengths, so a change in radius, core width or core thickness shifts the channel. The sensitivity to each is measured by a finite-difference sweep over the technology.
- Heater efficiency. A HEAT solve of the drawn modulator gives the ring temperature rise per milliwatt. Combined with the thermo-optic sensitivity from Component Library, that gives gigahertz per milliwatt.
- Thermal crosstalk. Silicon conducts heat, so a heater warms neighbouring rings as well as its own. A second HEAT solve with two rings at the layout pitch measures how much.
Together these give the trim range the heaters must cover, the power that costs, and the energy per bit it adds to the link.
from runpy import run_path
# Optical_IO_Chip.qmd defines what this notebook builds on: the chip and the channel# plan. 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/optical_io_chip.py").items() if not k.startswith("_")})import tidy3d as tdfrom tidy3d import web
p_probe = 1.0e-3 # Heater power used to probe the thermal response, W
# Read every layer height off the technology, so the two cannot drift apartstack = tech.parametric_kwargsz_wg = stack["core_thickness"] / 2 # mid core, where the mode isz_heater = stack["core_thickness"] + stack["heater_separation"]h_heater = stack["heater_thickness"]clad_top = ( z_heater + h_heater + stack["metal_separation"] + stack["metal_thickness"] + 1.0)
h_box = 2.0 # Buried oxide thickness, umdie_thickness = 200.0 # Silicon substrate thickness after back-grinding, um
lateral_pad = 150.0 # Oxide added around the cell to form the domain, umair_margin = 2.0 # Air above and below the die, so each boundary is an interface, umWhat fabrication moves
Section titled “What fabrication moves”A resonance sits where the round trip holds a whole number of wavelengths, so it follows the optical path length . Three fabrication parameters move it:
- Ring radius changes the round-trip length directly, so .
- Core width and core thickness change the effective index. Silicon has high index contrast, so both sensitivities are large.
The radius term is arithmetic. The other two come from a bend mode solve at the perturbed geometry.
The sweep runs at the technology level, since optical_io_soi takes the core width and thickness as
arguments and rebuilds the port specification with them, which keeps the solved mode consistent with
the geometry.
Step sizes are set by process control: a few nanometres on linewidth, a few more on SOI thickness.
The two spreads are treated separately. The wafer-scale spread is mostly common to eight rings within a millimetre of each other, and a common shift is corrected by moving the laser comb, at no cost on the chip. The within-die spread is the ring-to-ring difference, and only that has to be trimmed by the heaters.
# Finite-difference steps for the sensitivity, umsteps = {"core_width": 0.006, "core_thickness": 0.004}
# One-sigma geometry spreads, nm: across a wafer, then between rings on a diesigma_wafer = {"ring radius": 1.0, "core_width": 3.0, "core_thickness": 3.0}sigma_die = {"ring radius": 0.3, "core_width": 0.5, "core_thickness": 0.3}
def rib_index(**geometry): """Bend mode index of the modulator waveguide, at a given core geometry.""" # Rebuild the whole technology, so the port follows the geometry being swept t = optical_io_soi(**geometry) spec = t.ports["Rib_TE_1550_500"].copy() # Match the bend study's window and mode count, so only geometry differs spec.width, spec.limits, spec.added_solver_modes = ( 2 * r_target - 0.2, (-2.0, 2.0), 6, ) info = pf.port_modes( pf.Port((0, 0), 0, spec, bend_radius=r_target), frequencies=[f0], mesh_refinement=30, technology=t, verbose=False, show_progress=False, ).data.modes_info # Pick the guided mode by area, as the bend study does area = np.array(info["mode area"].values).ravel() return float(np.array(info["n eff"].values).ravel()[np.argmin(area)])
# Resonance sensitivity to each parameter, GHz per nmsens = {"ring radius": f0 / r_target * 1e-3 * 1e-9}
for name, step in steps.items(): base = stack[name] lo, hi = rib_index(**{name: base - step}), rib_index(**{name: base + step}) # Difference the pair to get dn_eff, then convert that to a frequency shift sens[name] = abs(f0 * (hi - lo) / (2 * step) / b["n_group"] * 1e-3 * 1e-9)
print( f"{'what':16s} {'GHz/nm':>8s} {'wafer nm':>9s} {'GHz':>7s} " f"{'die nm':>7s} {'GHz':>7s}")for k in sens: print( f"{k:16s} {sens[k]:8.1f} {sigma_wafer[k]:9.1f} " f"{sens[k]*sigma_wafer[k]:7.0f} {sigma_die[k]:7.1f} " f"{sens[k]*sigma_die[k]:7.0f}" )
# Total resonance spread, uncorrelated contributions in quadrature, GHzwafer = np.sqrt(sum((sens[k] * sigma_wafer[k]) ** 2 for k in sens))differential = np.sqrt(sum((sens[k] * sigma_die[k]) ** 2 for k in sens))print(f"{'in quadrature':16s} {'':8s} {'':9s} {wafer:7.0f} {'':7s} {differential:7.0f}")print()print( f"wafer to wafer {wafer:5.0f} GHz = {wafer/(fsr*1e-9):.2f} free spectral " f"ranges, taken out at the laser")print(f"ring to ring {differential:5.0f} GHz, one sigma, left to the heaters")
# Trim range the heaters must cover, GHz: three sigma either sidetrim_range = 6 * differentialprint()print(f"three sigma either side is {trim_range:.0f} GHz of range")print(f"at {ghz_per_k_rib:.2f} GHz/K that is {trim_range/ghz_per_k_rib:.1f} K")print(f"against a modulator linewidth of {fwhm*1e-9:.0f} GHz")what GHz/nm wafer nm GHz die nm GHzring radius 38.7 1.0 39 0.3 12core_width 40.9 3.0 123 0.5 20core_thickness 130.2 3.0 391 0.3 39in quadrature 411 46
wafer to wafer 411 GHz = 0.17 free spectral ranges, taken out at the laserring to ring 46 GHz, one sigma, left to the heaters
three sigma either side is 274 GHz of rangeat 9.31 GHz/K that is 29.4 Kagainst a modulator linewidth of 114 GHzCore thickness dominates, at roughly three times the sensitivity of the radius per nanometre, and it is also the parameter least under the designer’s control, since it arrives with the wafer.
The last two columns separate the two spreads. Wafer to wafer the shift is a large fraction of a free spectral range, but common to all eight rings, so the laser comb absorbs it. Ring to ring within one die it is tens of gigahertz, and that is what the heaters must cover.
Heater efficiency
Section titled “Heater efficiency”The sensitivities above are in gigahertz. Heater power is in milliwatts. A HEAT solve connects them by giving the ring temperature rise per milliwatt.
That figure depends on the whole stack and the package, not on the heater alone: heat leaves the ring through the buried oxide, spreads laterally in the silicon, and reaches the sink at the back of the die, so the die thickness enters the result.
Three points about the setup:
- The substrate and cladding are added explicitly.
Component.extrudereturns the device layers, which is what the optical and charge solves need; a thermal problem also needs the material the heat flows into. - The sink sits on a material interface, not on the domain edge, so the domain extends into air below the wafer.
- Only the ring track is powered. The heater is one conductor from terminal to terminal, but dissipation follows current density, and the feed is drawn wide so little power lands there. A uniform rate over the whole polygon would put the hottest point in the fan-out.
# Heater track width and feed slot width, read off the drawn cell, umheater_width = mrm.parametric_kwargs["heater_width"]heater_slot = mrm.parametric_kwargs["heater_slot"]
inset = 0.05 * heater_width # Gap from the powered annulus to the metal faces, umarc_r_out = r_target + heater_width / 2 - insetarc_r_in = r_target - heater_width / 2 + insetarc_height = 0.92 * h_heaterarc_fraction = 1 - 2 * np.arcsin(0.5 * heater_slot / r_target) / (2 * np.pi)arc_volume = np.pi * (arc_r_out**2 - arc_r_in**2) * arc_height * arc_fraction
def arc_source(dx, name): """Overlay marking the powered part of one heater, centred at x = dx.
An annulus with the feed slot removed, in the same metal as the track it sits inside, so it changes nothing but gives the source a region to act on. """ z_mid = z_heater + h_heater / 2 annulus = td.ClipOperation( operation="difference", geometry_a=td.Cylinder( center=(dx, 0, z_mid), radius=arc_r_out, length=arc_height, axis=2 ), geometry_b=td.Cylinder( center=(dx, 0, z_mid), radius=arc_r_in, length=arc_height, axis=2 ), ) return td.Structure( geometry=td.ClipOperation( operation="difference", geometry_a=annulus, # Cut with a taller box, so its faces coincide with nothing geometry_b=td.Box( center=(dx, -r_target, z_mid), size=(heater_slot, heater_slot, 3 * h_heater), ), ), medium=tin["electrical"], name=name, )
def thermal(pitch=None, power=p_probe): """Solve one ring, or two at a given pitch with only the left one driven.
Returns the temperature rise on the driven ring, the rise on the neighbour (nan when there is only one ring), and the data itself for plotting. """ # Place one modulator per ring, then the powered overlay on each heater offsets = (0.0,) if pitch is None else (0.0, pitch) structures = [] for dx in offsets: for s in mrm.extrude(classification="electrical"): t = s.to_tidy3d() structures.append(t.updated_copy(geometry=t.geometry.translated(dx, 0, 0))) structures += [arc_source(dx, f"heater{k}") for k, dx in enumerate(offsets)]
# Size the domain: the cell, oxide around it, the die below, air either side (cx0, cy0), (cx1, cy1) = mrm.bounds() x0, x1 = cx0 - lateral_pad, cx1 + (pitch or 0.0) + lateral_pad y0, y1 = cy0 - lateral_pad, cy1 + lateral_pad z0, z1 = -h_box - die_thickness - air_margin, clad_top + air_margin
# Add the box and the substrate, which the component extrusion does not draw wafer = td.Structure( geometry=td.Box.from_bounds((x0, y0, -h_box - die_thickness), (x1, y1, -h_box)), medium=si["electrical"], name="wafer", ) clad = td.Structure( geometry=td.Box.from_bounds((x0, y0, -h_box), (x1, y1, clad_top)), medium=sio2["electrical"], name="clad", )
# Refine a box around each ring, which distance grading alone would leave coarse refinement = [ td.GridRefinementRegion( center=(dx, 0, 0.5 * (z_heater - h_box)), size=(6 * r_target, 6 * r_target, 2 * h_box + z_heater + 4), dl_internal=0.6, transition_thickness=1.5 * r_target, ) for dx in offsets ]
sim = td.HeatChargeSimulation( center=((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2), size=(x1 - x0, y1 - y0, z1 - z0), structures=[wafer, clad] + structures, medium=air["electrical"], # Drive the first ring only, as a volumetric rate over its heater arc sources=[td.HeatSource(structures=["heater0"], rate=power / arc_volume)], boundary_spec=[ td.HeatBoundarySpec( placement=td.MediumMediumInterface(mediums=["Air", "SiO2"]), condition=td.ConvectionBC( ambient_temperature=300, transfer_coeff=10e-12 ), ), # Sink the heat at the back of the die, on the air to silicon interface td.HeatBoundarySpec( placement=td.MediumMediumInterface(mediums=["Air", "Si"]), condition=td.TemperatureBC(temperature=300), ), ], monitors=[ td.TemperatureMonitor( size=(td.inf, td.inf, 0), center=(0, 0, z_wg), name="wg_plane", unstructured=True, ) ], grid_spec=td.DistanceUnstructuredGrid( dl_interface=h_heater / 2, dl_bulk=25.0, distance_interface=h_heater, distance_bulk=h_box + 2, # Leave the substrate interior coarse, since nothing reads it non_refined_structures=["wafer"], mesh_refinements=refinement, # Allow elements far smaller than the domain, which is otherwise the floor relative_min_dl=0.5 * (h_heater / 2) / max(x1 - x0, y1 - y0, z1 - z0), ), )
tag = "one" if pitch is None else f"pair{int(pitch)}" data = web.Job(simulation=sim, task_name=f"oio_heat_{tag}", verbose=False).run( path=f"heat_{tag}.hdf5" )
def rise(x_centre): # Average over the ring core, read off the waveguide-plane monitor t = data["wg_plane"].temperature pts = np.asarray(t.points.values) vals = np.asarray(t.values).reshape(-1) - 300.0 core = np.abs(np.hypot(pts[:, 0] - x_centre, pts[:, 1]) - r_target) < 0.25 return vals[core].mean()
return rise(0.0), (np.nan if pitch is None else rise(pitch)), data
driven, _, single_data = thermal()k_per_mw = driven / (p_probe * 1e3)ghz_per_mw = k_per_mw * ghz_per_k_rib
print( f"powered arc {arc_fraction*100:.1f} percent of the circumference, " f"{arc_volume:.2f} um^3")print(f"thermal impedance {k_per_mw:.2f} K/mW")print(f"tuning efficiency {ghz_per_mw:.1f} GHz/mW = {ghz_per_mw/0.125:.0f} pm/mW")print(f"one linewidth {fwhm*1e-9/ghz_per_mw:.2f} mW")powered arc 98.1 percent of the circumference, 3.67 um^3thermal impedance 3.57 K/mWtuning efficiency 33.2 GHz/mW = 266 pm/mWone linewidth 3.44 mWThe temperature field below shows how far the heat spreads. It fills the oxide around the ring and reaches into the substrate, and that spread sets the crosstalk measured in the next section.
The square outline around the ring is the 90 nm silicon slab, which the modulator draws as one rectangle 8.2 um out from the ring centre, covering the ring and the bus together. Silicon conducts heat at 148e-6 W/(um K) against 1.38e-6 for the surrounding oxide, a factor of 107, so heat entering the slab spreads along it and the isotherms take its outline. Its extent is a layout constraint rather than a thermal choice: the slab has to reach past the outer contact plug, or the metal would sit on oxide at its edge.
# Read the waveguide-plane monitor and convert to a rise above ambientt = single_data["wg_plane"].temperaturepts = np.asarray(t.points.values)vals = np.asarray(t.values).reshape(-1) - 300.0
fig, ax = plt.subplots(1, 2, figsize=(11, 3.8))
# Draw the temperature field, zoomed onto the ring from the die-scale domainzoom = 3 * r_targetsingle_data.plot_field("wg_plane", ax=ax[0], cmap="inferno")ax[0].set( xlim=(-zoom, zoom), ylim=(-zoom, zoom), aspect="equal", title=f"waveguide plane at {p_probe*1e3:.0f} mW, ambient 300 K",)
# Take a cut along y = 0, which crosses the ring twice and then runs awaycut = np.abs(pts[:, 1]) < 0.3order = np.argsort(pts[cut, 0])ax[1].semilogy( pts[cut, 0][order], np.maximum(vals[cut][order], 1e-4), color="#c8385a", lw=1.4)# Mark where the cut crosses the ring core, on both sidesfor sign in (-1, 1): ax[1].axvline( sign * r_target, color="#1f4e79", ls="--", lw=1, label="ring core" if sign > 0 else None, )
# Mark where the neighbouring ring sits, which is what the tail has to reachax[1].axvline( channel_pitch, color="#2e7d5b", ls=":", lw=1.4, label=f"next ring at {channel_pitch:.0f} um",)ax[1].set_title("along y = 0", fontsize=9)ax[1].set_xlabel("x (um)")ax[1].set_ylabel("temperature rise (K)")ax[1].set_xlim(-20, channel_pitch * 1.2)ax[1].legend(fontsize=8)ax[1].grid(alpha=0.3, which="both")plt.tight_layout()plt.show()
Thermal crosstalk
Section titled “Thermal crosstalk”All eight rings share one substrate, so power into one heater raises the temperature of the others and the bank is a coupled system. The coupling is set by how far heat spreads laterally before reaching the sink, which scales with die thickness, and by the ring pitch.
The solve below places a second ring at the transmit bank’s pitch and drives only the first. The second ring’s temperature rise gives the coupling directly.
# Temperature rise on the driven ring and on its neighbour, Kdriven_pair, neighbour, _ = thermal(pitch=channel_pitch)
# Crosstalk: neighbour rise as a fraction of the driven ring'scoupling = neighbour / driven_pair
print(f"pitch {channel_pitch:.0f} um")print(f"driven ring {driven_pair:.3f} K")print(f"neighbour {neighbour:.4f} K")print(f"coupling {coupling:.5f}")print()
# Temperature the worst-case trim demands of one ring, Kworst_trim_k = trim_range / ghz_per_k_rib
# Resonance shift seven neighbours at that trim impose on one channel, GHzneighbours_ghz = (n_channels - 1) * coupling * worst_trim_k * ghz_per_k_ribprint(f"worst trim on one ring {worst_trim_k:.1f} K")print(f"seven neighbours then add {neighbours_ghz:.2f} GHz")print(f"one linewidth is {fwhm*1e-9:.0f} GHz")print(f"margin {fwhm*1e-9/max(neighbours_ghz, 1e-9):.0f}x")pitch 150 umdriven ring 3.541 Kneighbour 0.0003 Kcoupling 0.00008
worst trim on one ring 29.4 Kseven neighbours then add 0.15 GHzone linewidth is 114 GHzmargin 771xThe margin comes from the pitch, which is set by packaging rather than by thermal design: the bond pads in Chip Layout need 150 um per channel. At that spacing the coupling is several orders below the linewidth, so the eight channels can be controlled independently.
This is a result for this geometry, not a general one. Changing die_thickness or channel_pitch
and re-running the same function gives a different coupling.
Tuning power
Section titled “Tuning power”A heater can only raise a temperature, so the bank is drawn to the blue of its channels and every ring is corrected upward. The trim a given ring needs is then distributed between zero and the full spread rather than centred on zero, so the average is half the range and the worst case is all of it.
mean_mw = trim_range / 2 / ghz_per_mwworst_mw = trim_range / ghz_per_mw
print(f"{'':28s} {'per ring':>12s} {'eight rings':>14s}")print(f"{'average trim':28s} {mean_mw:9.2f} mW {8*mean_mw:11.1f} mW")print(f"{'worst case':28s} {worst_mw:9.2f} mW {8*worst_mw:11.1f} mW")print()
# Aggregate line rate across the eight channels, bit/sline_rate = n_channels * 100e9print(f"tuning energy, average {8*mean_mw*1e-3/line_rate*1e15:.1f} fJ/bit")print(f"tuning energy, worst case {8*worst_mw*1e-3/line_rate*1e15:.1f} fJ/bit")
# Save the measured constants, so the link notebook need not repeat these solvesnp.savez( "misc/thermal_budget.npz", k_per_mw=k_per_mw, ghz_per_mw=ghz_per_mw, coupling=coupling, trim_range=trim_range, differential=differential, mean_mw=mean_mw, worst_mw=worst_mw, pitch=channel_pitch, z_heater=z_heater, h_heater=h_heater, h_box=h_box, clad_top=clad_top, die_thickness=die_thickness, lateral_pad=lateral_pad, heater_width=heater_width, arc_volume=arc_volume,) per ring eight ringsaverage trim 4.12 mW 33.0 mWworst case 8.24 mW 65.9 mW
tuning energy, average 41.2 fJ/bittuning energy, worst case 82.4 fJ/bitSummary
Section titled “Summary”- Core thickness dominates the resonance spread, at roughly three times the sensitivity of the ring radius per nanometre, and it is fixed by the wafer rather than by the mask.
- The wafer-scale spread is common mode. Rings a millimetre apart share most of it, so it is corrected by moving the laser comb. Correcting it with heaters instead would cost hundreds of milliwatts.
- The within-die spread is what the heaters carry, tens of gigahertz against tens of gigahertz per milliwatt, so the eight rings trim for a few milliwatts in total.
- Thermal crosstalk is negligible at this pitch, which the bond pads set, not the thermal design.
Link Simulation sets every heater on the chip from these numbers and drives the result with data.