Avalanche Photodiode

An avalanche photodiode (APD) is a photodiode with internal gain. Each photogenerated carrier is accelerated by a high reverse-bias field until it creates new electron-hole pairs by impact ionization, so the primary photocurrent is multiplied by a mean gain . The AvalanchePhotodiodeTimeStepper models this device for time-domain simulations. The output current is
where is the primary responsivity at unity gain, is the bulk dark current, which passes through the multiplication region and is multiplied with the signal, and is the surface dark current, which is not.
The gain is not free. Three effects set how much of it is useful:
- Bias dependence: rises steeply as the reverse bias approaches breakdown.
- Excess noise: impact ionization is a random process, so the shot noise grows faster than the signal, by the excess noise factor .
- Gain-bandwidth product: the avalanche takes time to build up, so above some gain the bandwidth falls as .
This guide shows how to set up the model, how the gain follows the bias [1], how the bandwidth depends on the gain, how to find the gain that maximizes the signal-to-noise ratio [2], and how much an APD improves a 10 Gb/s receiver over a PIN photodiode. Basic detection with a PIN photodiode and a transimpedance amplifier (TIA) is covered in the Photodiode guide.
References
- Miller, S. L. “Avalanche breakdown in germanium.” Physical Review 1955 99 (4), 1234-1241, doi: 10.1103/PhysRev.99.1234.
- McIntyre, R. J. “Multiplication noise in uniform avalanche diodes.” IEEE Transactions on Electron Devices 1966 13 (1), 164-168, doi: 10.1109/T-ED.1966.15651.
import matplotlib.pyplot as pltimport numpy as npimport photonforge as pfimport photonforge.abstract as pfafrom math import erfc
viewer = pf.live_viewer.LiveViewer()LiveViewer started at http://127.0.0.1:38909. Call its 'stop()' method to free the port.The simulation constants come first. pf.C_0 is the speed of light in µm/s, so dividing it by a wavelength in µm gives the carrier frequency in Hz. The APD responds to optical power only, so the carrier frequency does not change its output, but every time-domain setup needs one. A time step of 1 ps resolves signals up to 500 GHz, far above the bandwidth of the device.
# Set the basic technology as the default for the abstract componentstech = pf.basic_technology()pf.config.default_technology = tech
# Define the O-band carrier wavelength (µm) and its frequency (Hz)lda0 = 1.31f0 = pf.C_0 / lda0
z0 = 50.0 # Reference impedance of the electrical port (Ω)q_e = 1.602176634e-19 # Elementary charge (C)time_step = 1e-12 # Simulation time step (s)The APD in this guide uses round, illustrative parameter values rather than those of a specific device:
- Primary responsivity: 0.6 A/W.
- Breakdown voltage: 30 V.
- Effective ionization ratio: .
- Gain-bandwidth product: 200 GHz.
- Intrinsic bandwidth: 10 GHz, the transit and RC limited bandwidth at low gain.
- Dark current: 0.1 µA of primary bulk dark current, which is multiplied, and 50 nA of surface dark current, which is not.
# Define the parameters of the APDresponsivity = 0.6 # Primary responsivity at unity gain (A/W)breakdown_voltage = 30.0 # Breakdown voltage (V)ionization_ratio = 0.1 # Effective ionization ratio kgain_bandwidth_product = 200e9 # Gain-bandwidth product (Hz)intrinsic_bandwidth = 10e9 # Transit and RC limited bandwidth (Hz)bulk_dark_current = 0.1e-6 # Primary bulk dark current (A)surface_dark_current = 50e-9 # Surface dark current (A)Gain Against Bias
Section titled “Gain Against Bias”The multiplication of an APD is commonly described by Miller’s empirical formula [1]
where is the reverse bias, the breakdown voltage and an exponent fitted to measurement. The function miller_gain tabulates this curve as an Interpolator, which is passed directly as the multiplication argument of the APD. Near breakdown the gain is clipped at max_multiplication (1000 by default), since the formula diverges there. For a device with separate absorption and multiplication layers, the optional punch_through_voltage holds the gain at 1 below the bias at which the absorber is depleted.
We use , within the range of 2 to 6 typical of measured junctions.
exponent = 3.0 # Miller exponent n
# Define a fine bias grid that stops just below breakdown (V)biases = np.linspace(0, 29.95, 500)
# Tabulate the Miller gain curve on the bias gridgain_curve = pf.miller_gain( biases, breakdown_voltage=breakdown_voltage, exponent=exponent)The avalanche_photodiode component from the abstract library uses the AvalanchePhotodiodeTimeStepper as its time-domain model. It has one optical input P0 and one electrical output E0, like the photodiode. With a gain curve as multiplication, the gain is read from the curve at bias. The bias is fixed during a simulation, so the gain is evaluated once when the time stepper is set up.
# Create the APD, with the gain read from the Miller curve at the given biasapd = pfa.avalanche_photodiode( responsivity=responsivity, multiplication=gain_curve, bias=20.0, # Reverse bias (V) bulk_dark_current=bulk_dark_current, dark_current=surface_dark_current, seed=0, # Seed of the noise generator)
# Display the component and its portsviewer(apd)A single component does not need a circuit around it: setup_time_stepper works on the APD directly, and the optical input is injected as a TimeSeries keyed by the port name P0@0. The input is the optical field amplitude , with the power in W, so a constant power is the constant amplitude . The APD output current is recovered from the electrical wave amplitude as , and with the default TIA gain of 1 V/A this is simply .
We sweep the bias and record the mean output current twice at each point: once with 1 µW of light and once in the dark. The difference of the two, divided by the unity-gain photocurrent , gives the gain. The first 1 ns of each run is discarded while the output filter settles.
power = 1e-6 # Optical input power (W)num_steps = 20000 # Number of time steps per runsettled = slice(1000, None) # Time steps kept after the output settles
# Define the bias points of the sweep (V)sweep_biases = np.linspace(15, 29.8, 25)
# Create lists for the mean current with light and in the darklight_current = []dark_current = []
for bias in sweep_biases: # Update the bias and set up the time stepper at this bias apd.update(bias=bias) time_stepper = apd.setup_time_stepper( time_step=time_step, carrier_frequency=f0, show_progress=False )
# Run once with light and once in the dark for p, currents in ((power, light_current), (0.0, dark_current)): # Reset the time stepper so that each run starts from the same state time_stepper.reset()
# Build the input field amplitude for a constant optical power field = np.full(num_steps, np.sqrt(p), dtype=complex) inputs = pf.TimeSeries(values={"P0@0": field}, time_step=time_step)
# Run the time-domain simulation output = time_stepper.step( inputs=inputs, time_step=time_step, show_progress=False )
# Convert the output wave amplitude to current, for a TIA gain of 1 V/A current = np.real(output["E0@0"]) * np.sqrt(z0)
# Store the mean current after the output settles currents.append(current[settled].mean())
# Convert the current lists to arrayslight_current = np.array(light_current)dark_current = np.array(dark_current)
# Extract the gain from the photocurrent above the dark currentmeasured_gain = (light_current - dark_current) / (responsivity * power)
# Plot the currents with light and in the dark against biasfig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.5), tight_layout=True)ax1.semilogy(sweep_biases, light_current * 1e6, "o-", ms=4, label="Light, 1 µW")ax1.semilogy(sweep_biases, dark_current * 1e6, "o-", ms=4, label="Dark")ax1.set(xlabel="Reverse bias (V)", ylabel="Current (µA)")ax1.legend()
# Plot the extracted gain with the Miller curve for comparisonax2.semilogy(sweep_biases, measured_gain, "o", ms=4, label="From simulation")ax2.semilogy(biases, gain_curve(biases), "k-", alpha=0.5, label="Miller curve")ax2.set(xlabel="Reverse bias (V)", ylabel="Gain M", xlim=(15, 30), ylim=(0.8, 200))_ = ax2.legend()
Both currents rise with the gain as the bias approaches breakdown, the dark current more slowly because its surface part is not multiplied. The gain extracted from the two currents follows the Miller curve that was passed to the model.
Bandwidth and Gain-Bandwidth Product
Section titled “Bandwidth and Gain-Bandwidth Product”At low gain the APD bandwidth is set by the carrier transit time and the RC time constant, as in a PIN photodiode. At high gain the avalanche needs several transits to build up, and the bandwidth falls as , where GBP is the gain-bandwidth product. The model combines the two limits into a single first-order response with the 3 dB bandwidth
with set by intrinsic_bandwidth and GBP by gain_bandwidth_product. The crossover between the two regimes is at for this device.
To measure the frequency response, the input power is modulated with several tones at once:
The photocurrent is linear in optical power, so each tone is detected independently of the others. Its amplitude in the output current, divided by the amplitude it would have without a bandwidth limit, gives the normalized response . Each tone frequency is placed on an exact bin of the discrete Fourier transform of the record, so each tone is read from a single bin without spectral leakage. We use eight tones between 1 and 60 GHz, an average power of 10 µW and a total modulation depth , which keeps the power positive at all times.
# Create an APD with both bandwidth limits and no dark currentapd_bw = pfa.avalanche_photodiode( responsivity=responsivity, ionization_ratio=ionization_ratio, gain_bandwidth_product=gain_bandwidth_product, intrinsic_bandwidth=intrinsic_bandwidth, seed=0,)
num_steps = 2**19 # Number of time steps in the recordaverage_power = 10e-6 # Average optical input power (W)modulation_index = 0.8 # Total modulation depth, shared equally by the tones
# Place the tone frequencies on exact Fourier bins of the recordrecord = num_steps * time_steptone_bins = np.round(np.geomspace(1e9, 60e9, 8) * record).astype(int)tones = tone_bins / recordtone_depth = modulation_index / tones.size
# Build the multi-tone input power and convert it to field amplitudet = np.arange(num_steps) * time_stepmodulation = np.cos(2 * np.pi * tones[:, None] * t).sum(axis=0)power_t = average_power * (1 + tone_depth * modulation)inputs = pf.TimeSeries(values={"P0@0": np.sqrt(power_t) + 0j}, time_step=time_step)
# Define the gains to compare and a fine frequency grid for the analytic responsegains = [5, 20, 40, 80]freq_fine = np.geomspace(0.5e9, 80e9, 300)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.5), tight_layout=True)for i, gain in enumerate(gains): # Update the gain and run the time-domain simulation apd_bw.update(multiplication=float(gain)) time_stepper = apd_bw.setup_time_stepper( time_step=time_step, carrier_frequency=f0, show_progress=False ) output = time_stepper.step(inputs=inputs, time_step=time_step, show_progress=False)
# Convert the output wave amplitude to current, for a TIA gain of 1 V/A current = np.real(output["E0@0"]) * np.sqrt(z0)
# Read each tone amplitude from its Fourier bin and normalize it tone_amplitude = 2 * np.abs(np.fft.rfft(current)[tone_bins]) / num_steps response = tone_amplitude / (gain * responsivity * average_power * tone_depth)
# Compute the combined 3 dB bandwidth and the analytic first-order response f_3db = 1 / np.sqrt( 1 / intrinsic_bandwidth**2 + (gain / gain_bandwidth_product) ** 2 ) response_theory = 1 / np.sqrt(1 + (freq_fine / f_3db) ** 2) print(f"M = {gain}: 3 dB bandwidth {f_3db / 1e9:.1f} GHz")
# Plot the simulated tones with the analytic response for comparison label = f"M = {gain}" ax1.semilogx(tones / 1e9, 20 * np.log10(response), "o", color=f"C{i}", label=label) ax1.semilogx( freq_fine / 1e9, 20 * np.log10(response_theory), "-", color=f"C{i}", alpha=0.5 )
# Mark this gain on the bandwidth curve ax2.loglog(gain, f_3db / 1e9, "o", color=f"C{i}")
# Mark the 3 dB level on the response plotax1.axhline(-3, color="k", ls=":", lw=1)ax1.set(xlabel="Modulation frequency (GHz)", ylabel="Normalized response (dB)")ax1.legend(fontsize=8)
# Compute the combined bandwidth over a wide range of gaingain_fine = np.geomspace(1, 200, 200)f_3db_fine = 1 / np.sqrt( 1 / intrinsic_bandwidth**2 + (gain_fine / gain_bandwidth_product) ** 2)
# Plot the combined bandwidth with its intrinsic and GBP / M limitsax2.loglog(gain_fine, f_3db_fine / 1e9, "k-", alpha=0.6, label="Combined")ax2.loglog( gain_fine, np.full_like(gain_fine, intrinsic_bandwidth / 1e9), "k:", label="Intrinsic",)ax2.loglog(gain_fine, gain_bandwidth_product / gain_fine / 1e9, "k--", label="GBP / M")ax2.set(xlabel="Gain M", ylabel="3 dB bandwidth (GHz)", ylim=(1, 30))_ = ax2.legend(fontsize=8)M = 5: 3 dB bandwidth 9.7 GHzM = 20: 3 dB bandwidth 7.1 GHzM = 40: 3 dB bandwidth 4.5 GHzM = 80: 3 dB bandwidth 2.4 GHz
On the left, the simulated tone amplitudes follow the first-order response for each gain, plotted as lines for reference. On the right, the bandwidth stays near GHz (dotted line) up to a gain of about 10 and then approaches the asymptote (dashed line). Setting either argument to zero removes that limit, and setting both to zero gives an APD with no bandwidth limit.
Excess Noise and the Optimum Gain
Section titled “Excess Noise and the Optimum Gain”Impact ionization is a random process: each primary carrier is multiplied by a random number whose mean is . The shot noise therefore grows as instead of . McIntyre’s excess noise factor [2] is
where is the ratio of the hole to electron ionization coefficients, set by ionization_ratio. For only one carrier type ionizes and saturates at 2. For both ionize equally and grows as . The one-sided current noise power spectral density (PSD) at the APD output is
where is the elementary charge and is the input-referred thermal noise PSD of the TIA, set by thermal_noise.
The signal current is , so for a CW input the signal-to-noise ratio (SNR) is
where is the noise bandwidth. For a first-order response . Without dark current, setting gives the gain that maximizes the SNR as the root of
At low gain the multiplied signal rises above the fixed thermal noise and the SNR improves as . At high gain the multiplied shot noise dominates, and since it grows faster than the signal, the SNR falls again. The optimum gain is higher for a small and for a large thermal noise.
We compare three values of : 0.02, typical of silicon, 0.1 for the APD of this guide, and 0.5, typical of InP. The input power is -29 dBm and the thermal noise is 15 pA/√Hz, a typical input-referred noise for a 10 Gb/s TIA. To keep the same noise bandwidth at every gain, the gain-bandwidth limit is left out here and only intrinsic_bandwidth is set. The SNR is measured directly from the output current as its squared mean divided by its variance.
power = 10 ** (-29 / 10) * 1e-3 # Optical input power (W)thermal_asd = 15e-12 # Input-referred thermal noise of the TIA (A/√Hz)num_steps = 2**17 # Number of time steps per runsettled = slice(1000, None) # Time steps kept after the output settles
# Compute the primary photocurrent, the thermal noise PSD and the noise bandwidthprimary_current = responsivity * powerthermal_noise = thermal_asd**2noise_bandwidth = np.pi / 2 * intrinsic_bandwidth
# Build the input field amplitude for a constant optical powerfield = np.full(num_steps, np.sqrt(power), dtype=complex)inputs = pf.TimeSeries(values={"P0@0": field}, time_step=time_step)
# Define the ionization ratios to comparematerials = {"Silicon": 0.02, "This APD": ionization_ratio, "InP": 0.5}
# Define the simulated gains and a fine gain grid for the analytic SNRgains = np.geomspace(1, 100, 13)gain_fine = np.geomspace(1, 100, 300)
# Create a dictionary for the optimum gain of each materialoptimum_gain = {}
fig, ax = plt.subplots(figsize=(7, 3.5), tight_layout=True)for i, (name, k) in enumerate(materials.items()): # Create an APD with this ionization ratio and only the intrinsic bandwidth apd_noise = pfa.avalanche_photodiode( responsivity=responsivity, ionization_ratio=k, intrinsic_bandwidth=intrinsic_bandwidth, thermal_noise=thermal_noise, seed=0, )
snr = [] for gain in gains: # Update the gain and run the time-domain simulation apd_noise.update(multiplication=float(gain)) time_stepper = apd_noise.setup_time_stepper( time_step=time_step, carrier_frequency=f0, show_progress=False ) output = time_stepper.step( inputs=inputs, time_step=time_step, show_progress=False )
# Convert the output to current and compute its SNR current = np.real(output["E0@0"])[settled] * np.sqrt(z0) snr.append(current.mean() ** 2 / current.var())
# Compute the analytic SNR from the excess noise factor excess = k * gain_fine + (1 - k) * (2 - 1 / gain_fine) shot = 2 * q_e * gain_fine**2 * excess * primary_current snr_theory = (gain_fine * primary_current) ** 2 / ( (shot + thermal_noise) * noise_bandwidth )
# Solve the cubic for the optimum gain and keep its real positive root roots = np.roots([k, 0, 1 - k, -thermal_noise / (q_e * primary_current)]) m_opt = roots[np.isreal(roots) & (roots.real > 0)].real[0] optimum_gain[name] = m_opt
# Plot the simulated and analytic SNR, and mark the optimum gain label = f"{name}, k = {k}" ax.semilogx(gains, 10 * np.log10(snr), "o", color=f"C{i}", label=label) ax.semilogx(gain_fine, 10 * np.log10(snr_theory), "-", color=f"C{i}", alpha=0.5) ax.axvline(m_opt, color=f"C{i}", ls=":", lw=1)
# Compare the best SNR with the SNR at unity gain improvement = 10 * np.log10(snr_theory.max() / snr_theory[0]) print(f"{label}: optimum gain {m_opt:.1f}, SNR improvement {improvement:.1f} dB")
ax.set(xlabel="Gain M", ylabel="SNR (dB)")_ = ax.legend(fontsize=8)Silicon, k = 0.02: optimum gain 44.9, SNR improvement 24.5 dBThis APD, k = 0.1: optimum gain 26.4, SNR improvement 22.1 dBInP, k = 0.5: optimum gain 15.5, SNR improvement 18.7 dB
The markers are the SNR of the simulated output current and the lines the analytic SNR, with the optimum gain of each material marked by a dotted line. All three curves start from the same thermally limited SNR at , where the APD behaves as a PIN photodiode, since . The lower the ionization ratio, the further the gain can be raised before the excess noise takes over, and the larger the improvement. The optimum also depends on the TIA noise, and rises with it.
Two other noise contributions follow the same equation. bulk_dark_current is multiplied and carries the same penalty as the signal, while dark_current, the surface part, adds unmultiplied shot noise.
Receiver Sensitivity at 10 Gb/s
Section titled “Receiver Sensitivity at 10 Gb/s”Finally, we compare two receivers detecting a 10 Gb/s non-return-to-zero (NRZ) signal at an average power of -29 dBm:
- A PIN photodiode, modeled with the photodiode component, with the same responsivity, bandwidth, dark current and TIA noise as the APD at unity gain.
- The APD, with its full set of parameters, biased at the voltage that gives the optimum gain found above. The bias is found by inverting the Miller curve.
The transmitter is ideal: the power is for a one and zero for a zero, with instantaneous transitions, so any closure of the eye comes from the receiver. The eye opening is quantified by the Q factor,
where and are the mean and standard deviation of the current sampled in the ones and in the zeros. For Gaussian noise the bit error ratio is . The best sampling instant is found by evaluating Q at every time step within one bit and keeping the largest. In the eye diagrams it is marked by a dashed line.
# Find the bias of the optimum gain by inverting the Miller curvem_opt = optimum_gain["This APD"]bias_opt = np.interp(m_opt, gain_curve(biases), biases)print(f"Optimum gain {m_opt:.1f} at a bias of {bias_opt:.2f} V")
# Create the PIN and APD receivers with the same responsivity and TIA noisereceivers = { "PIN photodiode": pfa.photodiode( responsivity=responsivity, filter_frequency=intrinsic_bandwidth, dark_current=bulk_dark_current + surface_dark_current, thermal_noise=thermal_noise, seed=0, ), "Avalanche photodiode": pfa.avalanche_photodiode( responsivity=responsivity, multiplication=gain_curve, bias=bias_opt, ionization_ratio=ionization_ratio, gain_bandwidth_product=gain_bandwidth_product, intrinsic_bandwidth=intrinsic_bandwidth, bulk_dark_current=bulk_dark_current, dark_current=surface_dark_current, thermal_noise=thermal_noise, seed=0, ),}
bit_rate = 10e9 # Bit rate (b/s)num_bits = 1024 # Number of bits in the sequenceskip_bits = 8 # Number of bits discarded while the receiver settlesnum_eye_bits = 300 # Number of bits overlaid in each eye diagram
# Build a random NRZ bit sequence as optical power and convert it to field amplitudesamples_per_bit = int(round(1 / bit_rate / time_step))rng = np.random.default_rng(1)bits = rng.integers(0, 2, num_bits)power_t = np.repeat(2 * power * bits, samples_per_bit)inputs = pf.TimeSeries(values={"P0@0": np.sqrt(power_t) + 0j}, time_step=time_step)
# Define the time axis of a two-bit window that starts half a bit earlyhalf_bit = samples_per_bit // 2eye_time = (np.arange(2 * samples_per_bit) - half_bit) * time_step
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5), tight_layout=True)for ax, (name, receiver) in zip(axes, receivers.items()): # Run the time-domain simulation for this receiver time_stepper = receiver.setup_time_stepper( time_step=time_step, carrier_frequency=f0, show_progress=False ) output = time_stepper.step(inputs=inputs, time_step=time_step, show_progress=False)
# Convert the output wave amplitude to current, for a TIA gain of 1 V/A current = np.real(output["E0@0"]) * np.sqrt(z0)
# Arrange the current in one row per bit and split the rows into ones and zeros samples = current.reshape(num_bits, samples_per_bit)[skip_bits:] ones = samples[bits[skip_bits:] == 1] zeros = samples[bits[skip_bits:] == 0]
# Evaluate Q at every sampling instant within the bit and keep the best q_factor = (ones.mean(0) - zeros.mean(0)) / (ones.std(0) + zeros.std(0)) phase = int(np.argmax(q_factor)) q_best = q_factor[phase]
# Estimate the bit error ratio from Q for Gaussian noise ber = 0.5 * erfc(q_best / np.sqrt(2)) print(f"{name}: Q = {q_best:.2f}, BER = {ber:.1e}")
# Overlay the two-bit windows to draw the eye diagram for b in range(skip_bits, num_eye_bits): start = b * samples_per_bit - half_bit window = current[start : start + 2 * samples_per_bit] ax.plot(eye_time * 1e12, window * 1e6, color="C0", alpha=0.05)
# Mark the best sampling instant ax.axvline(phase * time_step * 1e12, color="k", ls="--", lw=1) ax.set(xlabel="Time (ps)", ylabel="Current (µA)", title=f"{name}, Q = {q_best:.1f}")Optimum gain 26.4 at a bias of 29.61 VPIN photodiode: Q = 0.43, BER = 3.3e-01Avalanche photodiode: Q = 6.83, BER = 4.3e-12
At -29 dBm the PIN receiver is limited by the thermal noise of the TIA. The photocurrent of a one is about 1.5 µA, below the thermal noise current of about 1.9 µA rms in the receiver bandwidth, and the eye is closed. The APD multiplies the photocurrent by its optimum gain before the TIA adds its noise, which opens the eye to a Q factor above 6, a BER below 10⁻¹⁰. The bandwidth reduction that comes with the gain, from 10 GHz to about 6 GHz, still leaves the eye open at 10 Gb/s.
With a first-order response, the current of a one keeps rising until the next transition, so the best sampling instant falls late in the bit rather than at the center of the eye.
The same receiver can be placed at the end of any link built from abstract components, such as a modulated laser, a fiber and an optical amplifier, in the same way as the PIN photodiode.
Summary
Section titled “Summary”pfa.avalanche_photodiodemodels an APD whose output current is , exposed at the electrical port through the TIA gain as the wave amplitude .multiplicationsets the gain as a number, or as an interpolator of gain against bias evaluated atbias. miller_gain tabulates Miller’s formula [1], withpunch_through_voltageandmax_multiplicationbounding the curve.intrinsic_bandwidthandgain_bandwidth_productset a first-order response whose 3 dB bandwidth is flat at low gain and falls as at high gain.ionization_ratiosets McIntyre’s excess noise factor [2]. Together withthermal_noiseit determines the gain that maximizes the SNR, which is higher for a smaller ionization ratio.bulk_dark_currentis multiplied with the signal anddark_currentis not.saturation_currentandroll_offmodel space-charge saturation of the multiplied current, as in the Photodiode guide, andseedmakes noise realizations reproducible.