CW Laser

20f64e0a34764b3b86c9c92f6c120120

Time-domain circuit simulations in PhotonForge are built on time steppers: time-domain models that advance the signals in a photonic circuit sample by sample. Instead of the full optical field, time steppers operate on the slowly varying complex envelope around a global carrier frequency, and optical amplitudes are expressed in units of \(\sqrt{\mathrm{W}}\), so \(\lvert a \rvert^2\) is the instantaneous optical power.

Every time-domain simulation starts with a source. The CWLaserTimeStepper models a continuous-wave (CW) laser that emits the complex field

\[a[k] = \sqrt{P[k]} \, e^{j \phi[k]}\]

By default the source is ideal, with constant power and constant phase. Two noise mechanisms can be enabled to model realistic lasers: phase noise, parametrized by the Lorentzian linewidth of the laser, and relative intensity noise (RIN), modeled as white noise on the optical power.

This guide shows how to create the laser source and run it in a time-domain simulation, how to detune it from the carrier frequency, and how to configure and validate both noise mechanisms. At the end, the laser is connected to a photodetector to show how laser noise propagates through a circuit-level simulation.

Setup

[1]:
# Import plotting and numerics libraries
import matplotlib.pyplot as plt
import numpy as np

# Import PhotonForge and its abstract component library
import photonforge as pf
import photonforge.abstract as pfa

# Start the live viewer used to display the components
viewer = pf.live_viewer.LiveViewer()
LiveViewer started at http://127.0.0.1:41105. Call its 'stop()' method to free the port.
[2]:
# Create the technology and set it as the default
tech = pf.basic_technology()
pf.config.default_technology = tech

# Define the carrier of the simulation
lda0 = 1.55  # Carrier wavelength (µm)
f0 = pf.C_0 / lda0  # Carrier frequency (Hz)

# Define the optical power shared by all lasers in this guide
laser_power = 1e-3  # Laser output power (W)

An Ideal CW Source

The easiest way to use the model is through the abstract component library: cw_laser creates a component with a schematic symbol, a single optical port, and a TerminationModel that carries the CW laser time stepper as its time-domain counterpart. The power argument sets the mean optical output power in watts.

[3]:
# Create an ideal CW laser and display it
laser = pfa.cw_laser(power=laser_power)
viewer(laser)
[3]:
../_images/guides_CW_Laser_5_0.svg

A time-domain simulation is initialized by calling setup_time_stepper on the component, passing the time step and the carrier frequency. All signal envelopes are referenced to the carrier, which is handled automatically by the time stepper. Because the laser is a source, it needs no input signal: we call step with the desired number of steps and time step. The result is a TimeSeries whose keys combine the port name and mode index, "P0@0" in this case.

As expected for an ideal source, the output amplitude is a constant \(\sqrt{P}\), so the instantaneous power \(\lvert a \rvert^2\) stays at 1 mW.

[4]:
# Define the time grid of the simulation
time_step = 1e-12  # Simulation time step (s)
num_steps = 2000  # Number of simulation steps
t = np.arange(num_steps) * time_step

# Initialize the time stepper at the carrier frequency
time_stepper = laser.setup_time_stepper(time_step=time_step, carrier_frequency=f0)

# Run the simulation and read the envelope at the laser port
output = time_stepper.step(steps=num_steps, time_step=time_step)
a = output["P0@0"]

# Plot the optical power, which is the squared magnitude of the envelope
fig, ax = plt.subplots(figsize=(7, 3), tight_layout=True)
ax.plot(t * 1e9, np.abs(a) ** 2 * 1e3)
ax.set(xlabel="Time (ns)", ylabel="Optical power (mW)", ylim=(0, 1.2))
_ = ax.set_title("Ideal CW laser output")
Progress: 2000/2000
../_images/guides_CW_Laser_7_1.png

Laser Detuning

If the frequency argument is None (the default), the laser frequency equals the carrier frequency and the envelope is constant. Setting frequency to an absolute value detuned from the carrier by \(\Delta f\) makes the envelope rotate at that rate. PhotonForge uses the \(e^{-j 2 \pi f t}\) phasor convention for the physical field, so a laser at \(f_0 + \Delta f\) produces the envelope \(a(t) = \sqrt{P} \, e^{-j 2 \pi \Delta f t}\). When mapping an envelope spectrum computed with the standard FFT convention back to absolute optical frequencies, an envelope component at frequency \(f\) corresponds to the optical frequency \(f_0 - f\).

Below, the laser is detuned 5 GHz above the carrier: the envelope rotates with a 200 ps period and the spectrum shows a single tone at \(f - f_0 = 5\) GHz.

[5]:
# Create a laser detuned from the carrier
detuning = 5e9  # Laser frequency offset from the carrier (Hz)
laser_detuned = pfa.cw_laser(power=laser_power, frequency=f0 + detuning)

# Define a longer time grid to resolve the spectrum
num_steps = 4000  # Number of simulation steps
t = np.arange(num_steps) * time_step

# Initialize the time stepper and read the envelope
time_stepper = laser_detuned.setup_time_stepper(
    time_step=time_step, carrier_frequency=f0
)
a = time_stepper.step(steps=num_steps, time_step=time_step)["P0@0"]

# Compute the envelope spectrum
spectrum = np.abs(np.fft.fftshift(np.fft.fft(a))) / num_steps

# Convert FFT frequencies to optical offsets, flipping the sign for the e^{-j2πft} convention
freq_offset = -np.fft.fftshift(np.fft.fftfreq(num_steps, time_step))
order = np.argsort(freq_offset)

# Plot the envelope in time and its spectrum
plot_steps = 500  # Number of samples shown in the time trace
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3), tight_layout=True)
ax1.plot(t[:plot_steps] * 1e9, np.real(a[:plot_steps]), label="Real")
ax1.plot(t[:plot_steps] * 1e9, np.imag(a[:plot_steps]), label="Imaginary")
ax1.set(xlabel="Time (ns)", ylabel=r"Envelope amplitude ($\sqrt{\mathrm{W}}$)")
ax1.legend(loc="upper right", fontsize=8)
ax2.plot(freq_offset[order] / 1e9, spectrum[order])
ax2.set(xlabel="$f - f_0$ (GHz)", ylabel="Envelope spectrum", xlim=(-15, 15))

# Print the frequency of the spectral peak
print(f"Spectral peak at {freq_offset[np.argmax(spectrum)] / 1e9:.1f} GHz")
Progress: 4000/4000
Spectral peak at 5.0 GHz
../_images/guides_CW_Laser_9_1.png

Frequency Chirp

Swept-frequency sources such as FMCW lidar need a laser whose frequency ramps linearly in time. Three arguments turn the CW laser into a chirped source:

  • chirp_bandwidth: the total frequency excursion \(B\), centered on frequency (or on the carrier when frequency is None). Zero, the default, disables the chirp.

  • chirp_period: the duration \(T\) of one complete sweep. It must be set together with chirp_bandwidth; setting only one of the two has no effect and issues a warning.

  • chirp_waveform: "sawtooth" (default) ramps up over the whole period and resets, while "triangle" ramps up over the first half of the period and back down over the second.

The sweep rate is \(\gamma = B / T\) for the sawtooth and \(2B / T\) for the triangle. The time stepper integrates the instantaneous frequency step by step, so the accumulated phase follows the ideal quadratic ramp. The chirp adds to the detuning, the linewidth, and the RIN, which can all be enabled at the same time.

Below, both waveforms sweep 40 GHz in 1 ns around the carrier. The instantaneous frequency is recovered from the phase increment between consecutive samples, using the sign convention of the previous section.

[6]:
# Define the chirp and the time grid
chirp_bandwidth = 40e9  # Total frequency excursion of the sweep (Hz)
chirp_period = 1e-9  # Duration of one sweep (s)
num_steps = 3000  # Number of simulation steps
t = np.arange(num_steps) * time_step

# Simulate both waveforms and plot their instantaneous frequency
fig, ax = plt.subplots(figsize=(7, 3), tight_layout=True)
for waveform in ("sawtooth", "triangle"):
    # Create the chirped laser for this waveform
    laser_chirp = pfa.cw_laser(
        power=laser_power,
        chirp_bandwidth=chirp_bandwidth,
        chirp_period=chirp_period,
        chirp_waveform=waveform,  # Shape of the frequency sweep
    )

    # Run the simulation and read the envelope
    time_stepper = laser_chirp.setup_time_stepper(
        time_step=time_step, carrier_frequency=f0, show_progress=False
    )
    a = time_stepper.step(steps=num_steps, time_step=time_step, show_progress=False)[
        "P0@0"
    ]

    # Recover the frequency offset from the phase increment between samples
    inst_freq = -np.diff(np.unwrap(np.angle(a))) / (2 * np.pi * time_step)
    ax.plot(t[1:] * 1e9, inst_freq / 1e9, label=waveform)

    # Print the range covered by the sweep
    print(f"{waveform}: {inst_freq.min() / 1e9:.1f} to {inst_freq.max() / 1e9:.1f} GHz")

ax.set(xlabel="Time (ns)", ylabel="$f - f_0$ (GHz)")
_ = ax.legend(loc="upper right", fontsize=8)
sawtooth: -20.0 to 20.0 GHz
triangle: -20.0 to 20.0 GHz
../_images/guides_CW_Laser_11_1.png

The frequency stays within \(\pm B/2\) of the carrier, and the triangle sweeps twice as fast as the sawtooth over each half period. The output power is unchanged, since the chirp only modulates the phase.

Phase Noise and Linewidth

Real lasers have a finite spectral linewidth caused by spontaneous emission phase noise. The time stepper implements it as a discrete-time Wiener process: at each step, the phase receives an independent Gaussian increment with variance

\[\mathrm{Var}[\Delta \phi] = 2 \pi \, \Delta \nu \, \Delta t\]

where \(\Delta \nu\) is the linewidth argument, the full width at half maximum (FWHM) of the resulting Lorentzian field spectrum

\[S(f) \propto \frac{(\Delta \nu / 2)^2}{(f - f_\mathrm{laser})^2 + (\Delta \nu / 2)^2}\]

The seed argument makes each noise realization reproducible. We use a 100 MHz linewidth to keep the simulation short (single-frequency semiconductor lasers typically range from kHz to a few MHz) and check the two signatures of this noise model: the phase performs a random walk, and the averaged power spectrum of the field envelope follows the expected Lorentzian lineshape.

[7]:
# Create a laser with phase noise
linewidth = 100e6  # Lorentzian linewidth, full width at half maximum (Hz)
laser_pn = pfa.cw_laser(power=laser_power, linewidth=linewidth, seed=0)

# Define a coarser time grid and split it into segments for spectrum averaging
time_step_pn = 1e-11  # Simulation time step for the noise studies (s)
num_steps = 2**17  # Number of simulation steps
num_segments = 16  # Number of segments averaged in each spectrum
seg_len = num_steps // num_segments
t = np.arange(num_steps) * time_step_pn

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3), tight_layout=True)

# Accumulate the power spectrum over several noise realizations
num_seeds = 3  # Number of random seeds simulated
spectrum = np.zeros(seg_len)
for seed in range(num_seeds):
    # Run the laser with this seed
    laser_pn.update(seed=seed)
    time_stepper = laser_pn.setup_time_stepper(
        time_step=time_step_pn, carrier_frequency=f0, show_progress=False
    )
    a = time_stepper.step(steps=num_steps, time_step=time_step_pn, show_progress=False)[
        "P0@0"
    ]

    # Plot the random walk of the optical phase
    ax1.plot(t * 1e6, np.unwrap(np.angle(a)), label=f"seed={seed}")

    # Add the power spectrum of every segment
    for i in range(num_segments):
        seg = a[i * seg_len : (i + 1) * seg_len]
        spectrum += np.abs(np.fft.fftshift(np.fft.fft(seg))) ** 2

# Normalize the spectrum to its peak
spectrum /= spectrum.max()

# Compute the expected Lorentzian line shape for comparison
freq = np.fft.fftshift(np.fft.fftfreq(seg_len, time_step_pn))
lorentzian = 1 / (1 + (2 * freq / linewidth) ** 2)

# Measure the full width at half maximum of the simulated spectrum
crossings = freq[spectrum >= 0.5]
fwhm = crossings.max() - crossings.min()

# Plot the simulated spectrum against the Lorentzian
ax1.set(xlabel="Time (µs)", ylabel="Optical phase (rad)")
ax1.legend(fontsize=8)
ax2.plot(freq / 1e6, spectrum, label="Simulated")
ax2.plot(freq / 1e6, lorentzian, "--", label="Lorentzian")
ax2.set(xlabel="$f - f_0$ (MHz)", ylabel="Normalized PSD", xlim=(-500, 500))
ax2.legend(fontsize=8)

# Print the measured linewidth
print(f"Measured FWHM: {fwhm / 1e6:.0f} MHz (specified: {linewidth / 1e6:.0f} MHz)")
Measured FWHM: 85 MHz (specified: 100 MHz)
../_images/guides_CW_Laser_14_1.png

Relative Intensity Noise

Power fluctuations of a laser are characterized by the relative intensity noise power spectral density, \(\mathrm{RIN}(f) = S_{\delta P}(f) / P_0^2\), with units of 1/Hz and usually quoted in dB/Hz. The rel_intensity_noise argument sets a one-sided white RIN PSD: at each time step, the power receives an independent Gaussian fluctuation with variance

\[\mathrm{Var}[P] = \mathrm{RIN} \, \frac{P_0^2}{2 \, \Delta t}\]

which corresponds to the specified PSD band-limited by the Nyquist frequency of the simulation (the power is also clamped at zero). Typical semiconductor lasers show RIN levels between \(-160\) and \(-130\) dB/Hz. We configure \(-140\) dB/Hz and verify that the one-sided PSD of the simulated relative power fluctuations is white at the specified level.

[8]:
# Create a laser with relative intensity noise
rin_db = -140  # Relative intensity noise level (dB/Hz)
laser_rin = pfa.cw_laser(
    power=laser_power, rel_intensity_noise=10 ** (rin_db / 10), seed=0
)

# Run the simulation and compute the optical power
time_stepper = laser_rin.setup_time_stepper(
    time_step=time_step_pn, carrier_frequency=f0, show_progress=False
)
a = time_stepper.step(steps=num_steps, time_step=time_step_pn, show_progress=False)[
    "P0@0"
]
power = np.abs(a) ** 2

# Average the one-sided power fluctuation spectrum over all segments
psd = np.zeros(seg_len // 2 + 1)
for i in range(num_segments):
    seg = power[i * seg_len : (i + 1) * seg_len]
    psd += 2 * np.abs(np.fft.rfft(seg - seg.mean())) ** 2 * time_step_pn / seg_len

# Normalize by the squared mean power to obtain the RIN
psd /= num_segments * power.mean() ** 2
freq = np.fft.rfftfreq(seg_len, time_step_pn)

# Plot the power trace and the RIN spectrum against the specified level
plot_steps = 2000  # Number of samples shown in the time trace
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3), tight_layout=True)
ax1.plot(t[:plot_steps] * 1e9, power[:plot_steps] * 1e3)
ax1.set(xlabel="Time (ns)", ylabel="Optical power (mW)")
ax2.semilogx(freq[1:] / 1e9, 10 * np.log10(psd[1:]), label="Simulated")
ax2.axhline(rin_db, color="tab:orange", linestyle="--", label="Specified RIN")
ax2.set(xlabel="Frequency (GHz)", ylabel="RIN (dB/Hz)", ylim=(rin_db - 15, rin_db + 15))
ax2.legend(fontsize=8)

# Print the measured RIN
print(
    f"Measured RIN: {10 * np.log10(psd[1:].mean()):.1f} dB/Hz (specified: {rin_db} dB/Hz)"
)
Measured RIN: -140.0 dB/Hz (specified: -140 dB/Hz)
../_images/guides_CW_Laser_16_1.png

Laser Noise at a Detector

Abstract components can be wired into circuits like any other PhotonForge component. Here we connect the laser to a photodiode with responsivity \(R = 0.8\) A/W and compare the detected signal with the laser RIN disabled and enabled. The photodiode outputs the voltage \(V = G R P\), where \(G\) is the transimpedance gain (1 V/A by default), converted to an electrical wave amplitude \(V / \sqrt{Z_0}\) at its port.

The photodiode model generates its own shot noise, with a relative PSD of \(2 q / (R P_0)\), about \(-154\) dB/Hz at the detected power in this link. This sets the noise floor of the ideal link, while the laser RIN of \(-140\) dB/Hz sits 14 dB above it and dominates the detected noise. Since the parametric update method modifies the laser in place, the same circuit can be re-simulated with new laser parameters without rebuilding it.

[9]:
# Define the photodiode and the load
responsivity = 0.8  # Photodiode responsivity (A/W)
z0 = 50.0  # Load impedance (Ω)

# Create the laser and the photodiode
laser_link = pfa.cw_laser(power=laser_power)
photodiode = pfa.photodiode(responsivity=responsivity, seed=7)

# Connect the laser output to the photodiode input
link = pf.Component("detection_link")
laser_ref = link.add_reference(laser_link)
photodiode_ref = link.add_reference(photodiode)
photodiode_ref.connect("P0", laser_ref["P0"])

# Expose the electrical output and attach the circuit model
link.add_port(photodiode_ref["E0"])
link.add_model(pf.CircuitModel(), "Circuit")

# Simulate the link without and with laser RIN
voltages = {}
for label, rin in [("Ideal laser", 0), ("Laser with RIN", 10 ** (rin_db / 10))]:
    # Update the laser noise and run the circuit
    laser_link.update(rel_intensity_noise=rin, seed=3)
    time_stepper = link.setup_time_stepper(
        time_step=time_step_pn, carrier_frequency=f0, show_progress=False
    )
    output = time_stepper.step(
        steps=num_steps, time_step=time_step_pn, show_progress=False
    )

    # Convert the port amplitude to the voltage across the load
    voltages[label] = np.real(output["E0@0"]) * np.sqrt(z0)

# Compute the shot noise floor of the detected photocurrent
q_e = 1.602176634e-19  # Elementary charge (C)
shot_db = 10 * np.log10(2 * q_e / (responsivity * laser_power))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7, 3), tight_layout=True)
for label, v in voltages.items():
    # Plot the detected voltage trace
    ax1.plot(t[:plot_steps] * 1e9, v[:plot_steps] * 1e3, label=label, alpha=0.8)

    # Average the noise spectrum over all segments
    psd = np.zeros(seg_len // 2 + 1)
    for i in range(num_segments):
        seg = v[i * seg_len : (i + 1) * seg_len]
        psd += 2 * np.abs(np.fft.rfft(seg - seg.mean())) ** 2 * time_step_pn / seg_len

    # Normalize by the squared mean voltage and plot in dB
    psd /= num_segments * v.mean() ** 2
    ax2.semilogx(freq[1:] / 1e9, 10 * np.log10(psd[1:]), label=label, alpha=0.8)

    # Print the mean detected noise level
    print(f"{label}: detected noise PSD {10 * np.log10(psd[1:].mean()):.1f} dB/Hz")

# Mark the laser RIN and the shot noise floor
ax1.set(xlabel="Time (ns)", ylabel="Detected voltage (mV)")
ax1.legend(fontsize=8)
ax2.axhline(rin_db, color="k", linestyle="--", linewidth=1, label="Laser RIN")
ax2.axhline(shot_db, color="k", linestyle=":", linewidth=1, label="Shot noise floor")
ax2.set(
    xlabel="Frequency (GHz)", ylabel="Relative noise PSD (dB/Hz)", ylim=(-160, -130)
)
_ = ax2.legend(fontsize=8, loc="upper left")
Ideal laser: detected noise PSD -154.0 dB/Hz
Laser with RIN: detected noise PSD -139.8 dB/Hz
../_images/guides_CW_Laser_18_1.png

The detected noise matches the expected levels: the ideal-laser link is limited by the photodiode shot noise near \(-154\) dB/Hz, and enabling the laser RIN raises the detected noise to the configured \(-140\) dB/Hz.

Summary

  • The CW laser is the basic optical source for time-domain simulations: create it with pfa.cw_laser, initialize with setup_time_stepper (time step and carrier frequency), and run with step.

  • Optical envelope amplitudes are in \(\sqrt{\mathrm{W}}\), so \(\lvert a \rvert^2\) is the instantaneous power; the mean power is set by power.

  • frequency sets the absolute laser frequency: detuning from the carrier by \(\Delta f\) rotates the envelope as \(e^{-j 2 \pi \Delta f t}\).

  • chirp_bandwidth, chirp_period, and chirp_waveform sweep the laser frequency linearly for swept-source applications.

  • linewidth adds Wiener phase noise producing a Lorentzian line with the specified FWHM, and rel_intensity_noise adds a white one-sided RIN PSD; seed makes noise realizations reproducible.

  • Sources combine with modulators, waveguides, and detectors from the abstract module for circuit-level studies. For a directly modulated laser with rate-equation dynamics, see DMLaserTimeStepper, and to add optical noise to any existing signal, see OpticalNoiseTimeStepper.