Plotting

Author

Thomas Ogden

clerq.plot provides Plotly-based visualisation primitives for MBSolve and OBSolve results. Figures are interactive in the browser — hover to read values, drag to zoom, click the legend to toggle traces.

Installation

The plotting module requires the optional [plot] dependencies:

pip install clerq[plot]

or with uv:

uv pip install clerq[plot]

kaleido is included in [plot] and is required for static PNG export (fig.write_image()). If you only need interactive figures in a notebook you can install plotly alone.

Usage pattern

All plot functions take a solved MBSolve instance as their first argument and return a plotly.graph_objects.Figure. They never call .show() internally — that is always your call:

from clerq import plot

fig = plot.field_spacetime(mbs)         # returns a Figure
fig.show(renderer='notebook_connected') # interactive in Jupyter
fig.write_image('output.png')           # static PNG via kaleido

Setup: solve a two-level absorber

We use a weak Gaussian probe propagating through a two-level absorber. This is enough to demonstrate every primitive.

from clerq import mb_solve, plot

mb_solve_json = """
{
  "atom": {
    "num_states": 2,
    "decays": [{"channels": [[0, 1]], "rate": 1.0}],
    "fields": [
      {
        "label": "probe",
        "coupled_levels": [[0, 1]],
        "rabi_freq": 0.01,
        "rabi_freq_t_func": "gaussian",
        "rabi_freq_t_args": {"ampl": 1.0, "centre": 0.0, "fwhm": 1.0}
      }
    ]
  },
  "t_min": -3.0,
  "t_max": 6.0,
  "t_steps": 120,
  "z_min": 0.0,
  "z_max": 1.0,
  "z_steps": 20,
  "z_steps_inner": 2,
  "interaction_strengths": [2.0],
  "savefile": "plotting-two-level"
}
"""

mbs = mb_solve.MBSolve.from_json_str(mb_solve_json)
Omegas_zt, states_zt = mbs.mbsolve()

  0%|          | 0/20 [00:00<?, ?z/s]
  5%|▌         | 1/20 [00:00<00:00, 843.25z/s, Ω_max=0.0582]
 10%|█         | 2/20 [00:00<00:00, 260.82z/s, Ω_max=0.0501]
 15%|█▌        | 3/20 [00:00<00:00, 228.78z/s, Ω_max=0.0434]
 20%|██        | 4/20 [00:00<00:00, 221.07z/s, Ω_max=0.0375]
 25%|██▌       | 5/20 [00:00<00:00, 207.70z/s, Ω_max=0.0325]
 30%|███       | 6/20 [00:00<00:00, 200.60z/s, Ω_max=0.0282]
 35%|███▌      | 7/20 [00:00<00:00, 200.34z/s, Ω_max=0.0244]
 40%|████      | 8/20 [00:00<00:00, 202.34z/s, Ω_max=0.0212]
 45%|████▌     | 9/20 [00:00<00:00, 203.54z/s, Ω_max=0.0185]
 50%|█████     | 10/20 [00:00<00:00, 203.97z/s, Ω_max=0.0161]
 55%|█████▌    | 11/20 [00:00<00:00, 205.71z/s, Ω_max=0.0139]
 60%|██████    | 12/20 [00:00<00:00, 207.01z/s, Ω_max=0.0122]
 65%|██████▌   | 13/20 [00:00<00:00, 205.35z/s, Ω_max=0.0106]
 70%|███████   | 14/20 [00:00<00:00, 204.74z/s, Ω_max=0.00926]
 75%|███████▌  | 15/20 [00:00<00:00, 206.04z/s, Ω_max=0.00808]
 80%|████████  | 16/20 [00:00<00:00, 205.91z/s, Ω_max=0.00709]
 85%|████████▌ | 17/20 [00:00<00:00, 205.40z/s, Ω_max=0.0062] 
 90%|█████████ | 18/20 [00:00<00:00, 206.28z/s, Ω_max=0.00541]
 95%|█████████▌| 19/20 [00:00<00:00, 206.16z/s, Ω_max=0.00473]
100%|██████████| 20/20 [00:00<00:00, 206.38z/s, Ω_max=0.00417]
100%|██████████| 20/20 [00:00<00:00, 205.44z/s, Ω_max=0.00417]
Saving MBSolve to plotting-two-level.qu

field_spacetime — space-time heatmap of |Ω(z, t)|

The most common view: the full propagation in one figure. Hover over any pixel to read the exact (z, t, |Ω|) values.

fig = plot.field_spacetime(mbs)
fig.show(renderer='notebook_connected')

field_envelope — pulse shape at fixed z

Compare the input pulse (z = z_min) to the output pulse (z = z_max). The probe is absorbed as it propagates through the medium.

fig = plot.field_envelope(mbs, z_indices=[0, -1])
fig.show(renderer='notebook_connected')

field_z_profile_anim — animated spatial profile

Watch the pulse propagate through the medium. Each frame shows the field envelope \(|\Omega(z)|\) at a single instant. Use the slider to scrub to a specific time, or press ▶ Play to animate.

fig = plot.field_z_profile_anim(mbs)
fig.show(renderer='notebook_connected')

pulse_area — area theorem

The pulse area \(\mathcal{A} = \int |\Omega(z,t)|\, dt / \pi\) decreases from the input value as the probe is absorbed.

fig = plot.pulse_area(mbs)
fig.show(renderer='notebook_connected')

spectrum — frequency-domain absorption

Computes the absorption spectrum via Fourier transform of the time-domain field at z_idx (default: exit face). This works in the linear (weak-field) regime; for strong fields the result is the nonlinear transmission, not the susceptibility.

Key options:

  • freq_range — clip the displayed frequency axis to |f| ≤ freq_range (γ), hiding the noisy high-frequency wings.
  • show_dispersion=True — add the dispersive component on a secondary axis.
  • freq_scale="arcsinh" — compress the frequency axis nonlinearly (linear near zero, log-like at large |f|) so narrow and broad features coexist. arcsinh_scale sets the width of the linear region in γ.
  • window — apply a SciPy window (e.g. "hann") to the time-domain field before the FFT to reduce spectral leakage from truncated free-induction decay.
# Narrow window around the resonance with dispersion on secondary axis
fig = plot.spectrum(mbs, freq_range=3.0, show_dispersion=True)
fig.show(renderer='notebook_connected')
# Arcsinh frequency scale (linear ±1 γ, log-like beyond) with Hann windowing
# to reduce leakage from the free-induction decay tail
fig = plot.spectrum(mbs, freq_scale="arcsinh", arcsinh_scale=1.0, window="hann")
fig.show(renderer='notebook_connected')

population — state populations vs time

The diagonal elements \(\rho_{ii}(t)\) of the density matrix at a fixed z position. The probe drives population from |0⟩ to |1⟩ and back.

fig = plot.population(mbs, z_idx=0)
fig.show(renderer='notebook_connected')

population_spacetime — population heatmap

Like field_spacetime but for a density-matrix diagonal element. Useful for visualising population inversion, storage, and retrieval.

fig = plot.population_spacetime(mbs, state_idx=1)
fig.show(renderer='notebook_connected')

coherence — off-diagonal density matrix elements

Plot \(|\rho_{01}(t)|\), \(\mathrm{Re}(\rho_{01})\), or \(\mathrm{Im}(\rho_{01})\) at a fixed z. The coherence is proportional to the macroscopic polarisation of the medium and drives the field evolution.

fig = plot.coherence(mbs, 0, 1, z_idx=0, component='abs')
fig.show(renderer='notebook_connected')

Static PNG export

Any figure can be saved as a static PNG using fig.write_image(). This requires kaleido (included in clerq[plot]).

fig = plot.field_spacetime(mbs)
fig.write_image('field_spacetime.png', scale=2)  # scale=2 for high-DPI

SVG and PDF are also supported:

fig.write_image('field_spacetime.svg')
fig.write_image('field_spacetime.pdf')