Skip to content

Visualization

Generated from the docstrings in the source code.

volume_3d

3D volumetric rendering of wavefunctions: nested |ψ|² isosurfaces extracted with marching cubes, coloured by the local quantum phase arg ψ and lit with a simple Lambert model. Supports orbiting-camera animations (MP4/GIF) and interactive HTML export (Plotly).

Requires scikit-image (marching cubes); Plotly is only needed for HTML export.

IsosurfaceRenderer(grid: Grid3D, levels: Sequence[float] = (0.5, 0.15, 0.03), alphas: Optional[Sequence[float]] = None, color_by: str = 'phase', reference_peak: Optional[float] = None, potential=None, potential_level: float = 0.5, theme: str = 'dark', step_size: int = 1, figsize=(9, 8), light_direction=(0.4, -0.5, 0.75), show_axes: bool = True, zoom: float = 1.15, phase_cmap: str = PHASE_CMAP)

Render |ψ(x, y, z)|² as nested translucent isosurfaces.

Parameters:

Name Type Description Default
grid Grid3D
required
levels sequence of float

Isosurface levels as fractions of the reference peak density.

(0.5, 0.15, 0.03)
alphas sequence of float

Opacity per level (defaults fade from opaque inner to faint outer).

None
color_by (phase, level)

'phase' colours each face by arg ψ on a cyclic map; 'level' uses one colour per isosurface.

'phase'
reference_peak float

Density that levels refer to. If None, each frame uses its own peak. Pass the global peak of a series for a fixed, comparable scale.

None
potential Potential

Drawn as a grey translucent isosurface at potential_level · max V.

None
step_size int

Marching-cubes step (2 roughly quarters the triangle count).

1
Source code in quantumlab/visualization/volume_3d.py
def __init__(self, grid: Grid3D, levels: Sequence[float] = (0.5, 0.15, 0.03),
             alphas: Optional[Sequence[float]] = None, color_by: str = 'phase',
             reference_peak: Optional[float] = None, potential=None,
             potential_level: float = 0.5, theme: str = 'dark', step_size: int = 1,
             figsize=(9, 8), light_direction=(0.4, -0.5, 0.75), show_axes: bool = True,
             zoom: float = 1.15, phase_cmap: str = PHASE_CMAP):
    if not isinstance(grid, Grid3D):
        raise TypeError('IsosurfaceRenderer requires a Grid3D')
    if color_by not in ('phase', 'level'):
        raise ValueError("color_by must be 'phase' or 'level'")
    self.grid = grid
    self.levels = tuple(sorted(levels, reverse=True))
    n = len(self.levels)
    self.alphas = tuple(alphas) if alphas is not None else tuple(np.linspace(0.9, 0.18, n)) if n > 1 else (0.85,)
    self.color_by = color_by
    self.reference_peak = reference_peak
    self.theme = theme
    self.step_size = step_size
    self.figsize = figsize
    self.light = np.asarray(light_direction, dtype=float) / np.linalg.norm(light_direction)
    self.show_axes = show_axes
    self.zoom = zoom
    self.phase_cmap = phase_cmap
    self._potential_mesh = None
    if potential is not None:
        V = potential.evaluate(grid)
        if V.max() > V.min():
            self._potential_mesh = extract_isosurface(V, V.min() + potential_level * (V.max() - V.min()),
                                                      grid, step_size=1)
    self.fig = None
    self.ax = None

draw(psi: np.ndarray, t: Optional[float] = None, elev: float = 22.0, azim: float = -58.0, title: Optional[str] = None, note: Optional[str] = None, overlays: Optional[Sequence[dict]] = None)

Draw one frame (re-uses the figure across calls) and return it.

overlays is a list of extra opaque surfaces drawn underneath the translucent density shells, each a dict with keys field (array on the grid), level, and optionally color and alpha. Example: vortex cores as an isosurface of the normalised density.

Source code in quantumlab/visualization/volume_3d.py
def draw(self, psi: np.ndarray, t: Optional[float] = None, elev: float = 22.0,
         azim: float = -58.0, title: Optional[str] = None, note: Optional[str] = None,
         overlays: Optional[Sequence[dict]] = None):
    """
    Draw one frame (re-uses the figure across calls) and return it.

    ``overlays`` is a list of extra opaque surfaces drawn underneath the
    translucent density shells, each a dict with keys ``field`` (array on
    the grid), ``level``, and optionally ``color`` and ``alpha``. Example:
    vortex cores as an isosurface of the normalised density.
    """
    if self.fig is None:
        self._setup_axes()
    ax = self.ax
    ax.cla()
    if overlays:
        # Keep the drawing order: opaque overlays first, translucent shells on top
        ax.computed_zorder = False
        for ov in overlays:
            mesh = extract_isosurface(np.asarray(ov['field']), ov['level'], self.grid, self.step_size)
            if mesh is None:
                continue
            verts, faces, normals = mesh
            rgb = np.tile(to_rgb(ov.get('color', '#4fd8ff')), (len(faces), 1))
            ax.add_collection3d(Poly3DCollection(verts[faces], facecolors=self._shade(rgb, normals),
                                                 edgecolor='none', alpha=ov.get('alpha', 1.0)))
    psi = np.asarray(psi)
    density = np.abs(psi) ** 2
    peak = self.reference_peak if self.reference_peak is not None else density.max()

    if self._potential_mesh is not None:
        verts, faces, normals = self._potential_mesh
        grey = np.tile(to_rgb('#9aa0b5' if self.theme == 'dark' else '#7a7f90'), (len(faces), 1))
        coll = Poly3DCollection(verts[faces], facecolors=self._shade(grey, normals),
                                edgecolor='none', alpha=0.22)
        ax.add_collection3d(coll)

    level_colors = cm.plasma(np.linspace(0.95, 0.2, len(self.levels)))[:, :3]
    phase_norm = Normalize(-np.pi, np.pi)
    phase_cmap = plt.get_cmap(self.phase_cmap)
    # Draw outer (faint) shells first so inner shells are composited on top
    for level, alpha, lc in reversed(list(zip(self.levels, self.alphas, level_colors))):
        mesh = extract_isosurface(density, level * peak, self.grid, self.step_size)
        if mesh is None:
            continue
        verts, faces, normals = mesh
        if self.color_by == 'phase':
            centroids = verts[faces].mean(axis=1)
            rgb = phase_cmap(phase_norm(_sample_phase(psi, centroids, self.grid)))[:, :3]
        else:
            rgb = np.tile(lc, (len(faces), 1))
        coll = Poly3DCollection(verts[faces], facecolors=self._shade(rgb, normals),
                                edgecolor='none', alpha=alpha)
        ax.add_collection3d(coll)

    self._style_axes(elev, azim)
    levels_txt = ', '.join(f'{100 * lv:g}%' for lv in self.levels)
    ref_txt = 'global peak' if self.reference_peak is not None else 'frame peak'
    caption = f'isosurfaces of $|\\psi|^2$ at {levels_txt} of {ref_txt}'
    if note:
        caption = f'{caption}   |   {note}'
    self._title.set_text(title or '')
    self._caption.set_text(caption)
    self._time.set_text('' if t is None else f't = {t:6.2f}')
    return self.fig

IsosurfaceAnimation(output_path, grid: Grid3D, n_frames: int, fps: int = 20, dpi: int = 100, elev: float = 22.0, azim_start: float = -60.0, orbit_degrees: float = 90.0, title: Optional[str] = None, note: Optional[str] = None, elev_end: Optional[float] = None, **renderer_kwargs)

Streaming MP4/GIF writer: frames are rendered as they are added, so a long high-resolution simulation never has to keep its snapshots in memory.

Usage::

with IsosurfaceAnimation('out.mp4', grid, n_frames=120, reference_peak=p0) as anim:
    for ...:
        anim.add_frame(wf.psi, t)

The camera orbits from azim_start by orbit_degrees over n_frames; if elev_end is given the elevation moves linearly from elev to it.

Source code in quantumlab/visualization/volume_3d.py
def __init__(self, output_path, grid: Grid3D, n_frames: int, fps: int = 20, dpi: int = 100,
             elev: float = 22.0, azim_start: float = -60.0, orbit_degrees: float = 90.0,
             title: Optional[str] = None, note: Optional[str] = None,
             elev_end: Optional[float] = None, **renderer_kwargs):
    self.output_path = Path(output_path)
    self.output_path.parent.mkdir(parents=True, exist_ok=True)
    self.n_frames = n_frames
    self.dpi = dpi
    self.elev = elev
    self.elev_end = elev if elev_end is None else elev_end
    self.azim_start = azim_start
    self.orbit_degrees = orbit_degrees
    self.title = title
    self.note = note
    self.renderer = IsosurfaceRenderer(grid, **renderer_kwargs)
    self.renderer._setup_axes()
    if self.output_path.suffix.lower() == '.gif':
        self.writer = PillowWriter(fps=fps)
    else:
        self.writer = FFMpegWriter(fps=fps, codec='libx264',
                                   extra_args=['-pix_fmt', 'yuv420p', '-crf', '20'],
                                   metadata={'title': title or 'QuantumLab 3D evolution',
                                             'artist': 'QuantumLab'})
    self.frames_written = 0

extract_isosurface(field: np.ndarray, level: float, grid: Grid3D, step_size: int = 1)

Marching-cubes isosurface of a scalar field sampled on grid.

Returns (vertices in physical coordinates, faces, unit face normals), or None if the level is outside the field range.

Source code in quantumlab/visualization/volume_3d.py
def extract_isosurface(field: np.ndarray, level: float, grid: Grid3D, step_size: int = 1):
    """
    Marching-cubes isosurface of a scalar field sampled on ``grid``.

    Returns (vertices in physical coordinates, faces, unit face normals),
    or None if the level is outside the field range.
    """
    _require_skimage()
    if not (field.min() < level < field.max()):
        return None
    spacing = (grid.dx, grid.dy, grid.dz)
    verts, faces, _, _ = marching_cubes(field, level=level, spacing=spacing, step_size=step_size)
    verts = verts + np.array([grid.x[0], grid.y[0], grid.z[0]])
    tri = verts[faces]
    normals = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
    normals /= np.linalg.norm(normals, axis=1, keepdims=True) + 1e-30
    return verts, faces, normals

animate_isosurfaces(snapshots: Sequence[np.ndarray], times: Sequence[float], grid: Grid3D, output_path, fps: int = 20, dpi: int = 100, elev: float = 22.0, azim_start: float = -60.0, orbit_degrees: float = 90.0, title: Optional[str] = None, note: Optional[str] = None, fixed_scale: bool = True, progress: bool = True, **renderer_kwargs)

Render a series of 3D wavefunctions (complex arrays on grid) to MP4 or GIF while the camera orbits by orbit_degrees over the clip.

With fixed_scale=True the isosurface levels refer to the peak density of the whole series, so shrinking/spreading is shown faithfully.

Source code in quantumlab/visualization/volume_3d.py
def animate_isosurfaces(snapshots: Sequence[np.ndarray], times: Sequence[float], grid: Grid3D,
                        output_path, fps: int = 20, dpi: int = 100, elev: float = 22.0,
                        azim_start: float = -60.0, orbit_degrees: float = 90.0,
                        title: Optional[str] = None, note: Optional[str] = None,
                        fixed_scale: bool = True, progress: bool = True, **renderer_kwargs):
    """
    Render a series of 3D wavefunctions (complex arrays on ``grid``) to MP4 or
    GIF while the camera orbits by ``orbit_degrees`` over the clip.

    With ``fixed_scale=True`` the isosurface levels refer to the peak density
    of the whole series, so shrinking/spreading is shown faithfully.
    """
    if fixed_scale and 'reference_peak' not in renderer_kwargs:
        renderer_kwargs['reference_peak'] = max(float(np.max(np.abs(s) ** 2)) for s in snapshots)
    n = len(snapshots)
    with IsosurfaceAnimation(output_path, grid, n, fps=fps, dpi=dpi, elev=elev, azim_start=azim_start,
                             orbit_degrees=orbit_degrees, title=title, note=note,
                             **renderer_kwargs) as anim:
        for i, (psi, t) in enumerate(zip(snapshots, times)):
            anim.add_frame(psi, t)
            if progress and (i % 10 == 0 or i == n - 1):
                print(f'  rendered frame {i + 1}/{n}')
    return Path(output_path)

export_interactive_html(snapshots: Sequence[np.ndarray], times: Sequence[float], grid: Grid3D, output_path, level: float = 0.1, stride: int = 2, title: str = 'QuantumLab — 3D wavefunction')

Interactive, rotatable HTML (Plotly Mesh3d) of one |ψ|² isosurface per snapshot, coloured by phase, with a time slider. stride subsamples the grid to keep the file small. Plotly.js is loaded from its CDN.

Source code in quantumlab/visualization/volume_3d.py
def export_interactive_html(snapshots: Sequence[np.ndarray], times: Sequence[float], grid: Grid3D,
                            output_path, level: float = 0.1, stride: int = 2,
                            title: str = 'QuantumLab — 3D wavefunction'):
    """
    Interactive, rotatable HTML (Plotly Mesh3d) of one |ψ|² isosurface per
    snapshot, coloured by phase, with a time slider. ``stride`` subsamples the
    grid to keep the file small. Plotly.js is loaded from its CDN.
    """
    try:
        import plotly.graph_objects as go
    except ImportError as err:
        raise ImportError('Interactive export requires plotly: pip install plotly') from err
    _require_skimage()
    sub = Grid3D(len(grid.x[::stride]), grid.x[0], grid.x[0] + grid.L_x,
                 len(grid.y[::stride]), grid.y[0], grid.y[0] + grid.L_y,
                 len(grid.z[::stride]), grid.z[0], grid.z[0] + grid.L_z)
    peak = max(float(np.max(np.abs(s) ** 2)) for s in snapshots)

    def mesh_for(psi):
        psi = np.asarray(psi)[::stride, ::stride, ::stride]
        mesh = extract_isosurface(np.abs(psi) ** 2, level * peak, sub)
        if mesh is None:
            return go.Mesh3d(x=[], y=[], z=[])
        verts, faces, _ = mesh
        phase = _sample_phase(psi, verts, sub)
        return go.Mesh3d(x=verts[:, 0], y=verts[:, 1], z=verts[:, 2],
                         i=faces[:, 0], j=faces[:, 1], k=faces[:, 2],
                         intensity=phase, cmin=-np.pi, cmax=np.pi, colorscale='Twilight',
                         colorbar=dict(title='arg ψ'), flatshading=False,
                         lighting=dict(ambient=0.45, diffuse=0.8, specular=0.3), opacity=0.9)

    frames = [go.Frame(data=[mesh_for(s)], name=f'{t:.2f}') for s, t in zip(snapshots, times)]
    fig = go.Figure(data=frames[0].data, frames=frames)
    steps = [dict(method='animate', label=f.name,
                  args=[[f.name], dict(mode='immediate', frame=dict(duration=0, redraw=True))]) for f in frames]
    fig.update_layout(
        title=f'{title}  (|ψ|² = {100 * level:g}% of peak)', template='plotly_dark',
        scene=dict(xaxis_title='x', yaxis_title='y', zaxis_title='z', aspectmode='data'),
        sliders=[dict(steps=steps, currentvalue=dict(prefix='t = '))],
        updatemenus=[dict(type='buttons', showactive=False, buttons=[
            dict(label='▶ Play', method='animate',
                 args=[None, dict(frame=dict(duration=120, redraw=True), fromcurrent=True)]),
            dict(label='⏸ Pause', method='animate',
                 args=[[None], dict(mode='immediate', frame=dict(duration=0, redraw=False))])])])
    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fig.write_html(str(output_path), include_plotlyjs='cdn')
    return output_path

plots_1d

plots_2d

plot_density_2d(wf, potential=None, title: str = '2D Probability Density $|\\Psi(x,y)|^2$', save_path: str = None, show: bool = True, theme: str = 'light', log_scale: bool = False)

Heatmap of the 2D probability density |ψ(x,y)|² with optional potential overlay.

Parameters:

Name Type Description Default
wf WaveFunction2D

The wavefunction to visualise.

required
potential Potential

If provided, draws a contour overlay of V(x, y).

None
title str

Plot title.

'2D Probability Density $|\\Psi(x,y)|^2$'
save_path str

File path to save the figure (PNG, PDF, etc.).

None
show bool

Whether to call plt.show().

True
theme str

'light' or 'dark'.

'light'
log_scale bool

Use logarithmic colour scale (useful for tunnelling tails).

False
Source code in quantumlab/visualization/plots_2d.py
def plot_density_2d(wf, potential=None, title: str = '2D Probability Density $|\\Psi(x,y)|^2$',
                    save_path: str = None, show: bool = True, theme: str = 'light',
                    log_scale: bool = False):
    """
    Heatmap of the 2D probability density |ψ(x,y)|² with optional potential overlay.

    Parameters
    ----------
    wf : WaveFunction2D
        The wavefunction to visualise.
    potential : Potential, optional
        If provided, draws a contour overlay of V(x, y).
    title : str
        Plot title.
    save_path : str, optional
        File path to save the figure (PNG, PDF, etc.).
    show : bool
        Whether to call plt.show().
    theme : str
        'light' or 'dark'.
    log_scale : bool
        Use logarithmic colour scale (useful for tunnelling tails).
    """
    set_style(theme)
    cmap = 'viridis' if theme == 'light' else 'inferno'
    bg_color = 'white' if theme == 'light' else '#111111'

    fig, ax = plt.subplots(figsize=(8, 7))
    fig.patch.set_facecolor(bg_color)
    ax.set_facecolor(bg_color)

    x = wf.grid.x
    y = wf.grid.y
    density = wf.probability_density.T   # transpose so x→col, y→row for imshow

    extent = [x.min(), x.max(), y.min(), y.max()]
    norm = LogNorm(vmin=max(density.max() * 1e-6, density[density > 0].min()),
                   vmax=density.max()) if log_scale else None

    im = ax.imshow(density, origin='lower', extent=extent, aspect='equal',
                   cmap=cmap, interpolation='bilinear', norm=norm)

    if potential is not None:
        V = potential.evaluate(wf.grid).T
        V_norm = (V - V.min()) / (V.max() - V.min() + 1e-30)
        contour_color = '#ff4444' if theme == 'light' else '#ffaa00'
        ax.contour(x, y, V_norm, levels=5, colors=contour_color, linewidths=0.8, alpha=0.7)

    cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
    cbar.set_label('Probability Density $|\\Psi|^2$', fontsize=10)
    ax.set_xlabel('x', fontsize=11)
    ax.set_ylabel('y', fontsize=11)
    ax.set_title(title, fontsize=13, fontweight='bold', pad=14)

    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=300, bbox_inches='tight', facecolor=bg_color)
    if show:
        plt.show()
    else:
        plt.close(fig)

plot_density_snapshots_2d(wf_list, times, potential=None, ncols: int = 4, title: str = '2D Wave Packet Evolution', save_path: str = None, show: bool = True, theme: str = 'light')

Grid of |ψ|² heatmap snapshots at different times.

Parameters:

Name Type Description Default
wf_list list of WaveFunction2D

Ordered list of wavefunctions (one per snapshot).

required
times array - like

Corresponding time values for axis labels.

required
ncols int

Number of columns in the snapshot grid.

4
Source code in quantumlab/visualization/plots_2d.py
def plot_density_snapshots_2d(wf_list, times, potential=None, ncols: int = 4,
                               title: str = '2D Wave Packet Evolution',
                               save_path: str = None, show: bool = True,
                               theme: str = 'light'):
    """
    Grid of |ψ|² heatmap snapshots at different times.

    Parameters
    ----------
    wf_list : list of WaveFunction2D
        Ordered list of wavefunctions (one per snapshot).
    times : array-like
        Corresponding time values for axis labels.
    ncols : int
        Number of columns in the snapshot grid.
    """
    set_style(theme)
    cmap = 'viridis' if theme == 'light' else 'inferno'
    bg_color = 'white' if theme == 'light' else '#111111'
    text_color = 'black' if theme == 'light' else 'white'

    n = len(wf_list)
    nrows = (n + ncols - 1) // ncols
    fig, axes = plt.subplots(nrows, ncols, figsize=(ncols * 3.5, nrows * 3.2))
    fig.patch.set_facecolor(bg_color)
    axes = np.array(axes).reshape(-1)

    # Shared colour scale across all frames
    global_max = max(wf.probability_density.max() for wf in wf_list)

    x = wf_list[0].grid.x
    y = wf_list[0].grid.y
    extent = [x.min(), x.max(), y.min(), y.max()]

    for i, (wf, t) in enumerate(zip(wf_list, times)):
        ax = axes[i]
        ax.set_facecolor(bg_color)
        density = wf.probability_density.T
        ax.imshow(density, origin='lower', extent=extent, aspect='equal',
                  cmap=cmap, interpolation='bilinear',
                  vmin=0.0, vmax=global_max)
        if potential is not None:
            V = potential.evaluate(wf.grid).T
            V_norm = (V - V.min()) / (V.max() - V.min() + 1e-30)
            contour_color = '#ff4444' if theme == 'light' else '#ffaa00'
            ax.contour(x, y, V_norm, levels=3, colors=contour_color,
                       linewidths=0.6, alpha=0.6)
        ax.set_title(f't = {t:.2f}', fontsize=9, color=text_color)
        ax.set_xticks([])
        ax.set_yticks([])

    for j in range(i + 1, len(axes)):
        axes[j].set_visible(False)

    fig.suptitle(title, fontsize=13, fontweight='bold', color=text_color, y=1.01)
    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=200, bbox_inches='tight', facecolor=bg_color)
    if show:
        plt.show()
    else:
        plt.close(fig)

plot_orthogonal_slices_3d(wf3d, potential=None, title: str = '3D Wave Packet — Orthogonal Slices', save_path: str = None, show: bool = True, theme: str = 'light')

Three-panel plot showing |ψ|² slices through the centroid of a 3D wavefunction: XY (z=centre), XZ (y=centre), YZ (x=centre).

Parameters:

Name Type Description Default
wf3d WaveFunction3D

The 3D wavefunction to slice.

required
potential Potential

Draws a contour overlay on each panel.

None
Source code in quantumlab/visualization/plots_2d.py
def plot_orthogonal_slices_3d(wf3d, potential=None,
                               title: str = '3D Wave Packet — Orthogonal Slices',
                               save_path: str = None, show: bool = True,
                               theme: str = 'light'):
    """
    Three-panel plot showing |ψ|² slices through the centroid of a 3D wavefunction:
    XY (z=centre), XZ (y=centre), YZ (x=centre).

    Parameters
    ----------
    wf3d : WaveFunction3D
        The 3D wavefunction to slice.
    potential : Potential, optional
        Draws a contour overlay on each panel.
    """
    set_style(theme)
    cmap = 'viridis' if theme == 'light' else 'inferno'
    bg_color = 'white' if theme == 'light' else '#111111'
    text_color = 'black' if theme == 'light' else 'white'
    contour_color = '#ff4444' if theme == 'light' else '#ffaa00'

    grid = wf3d.grid
    cx = grid.N_x // 2
    cy = grid.N_y // 2
    cz = grid.N_z // 2

    # Find centroid of probability mass for more informative slicing
    prob = wf3d.probability_density
    total = prob.sum()
    if total > 0:
        cx = int(np.round(np.sum(np.arange(grid.N_x)[:, None, None] * prob) / total))
        cy = int(np.round(np.sum(np.arange(grid.N_y)[None, :, None] * prob) / total))
        cz = int(np.round(np.sum(np.arange(grid.N_z)[None, None, :] * prob) / total))
        cx = np.clip(cx, 0, grid.N_x - 1)
        cy = np.clip(cy, 0, grid.N_y - 1)
        cz = np.clip(cz, 0, grid.N_z - 1)

    slice_xy = prob[:, :, cz].T
    slice_xz = prob[:, cy, :].T
    slice_yz = prob[cx, :, :].T

    global_max = max(slice_xy.max(), slice_xz.max(), slice_yz.max())

    fig = plt.figure(figsize=(15, 5))
    fig.patch.set_facecolor(bg_color)
    gs = gridspec.GridSpec(1, 3, figure=fig, wspace=0.35)

    panels = [
        (gs[0], slice_xy, grid.x, grid.y, 'x', 'y', f'XY  (z = {grid.z[cz]:.1f})'),
        (gs[1], slice_xz, grid.x, grid.z, 'x', 'z', f'XZ  (y = {grid.y[cy]:.1f})'),
        (gs[2], slice_yz, grid.y, grid.z, 'y', 'z', f'YZ  (x = {grid.x[cx]:.1f})'),
    ]

    for spec, data, ax_arr, ay_arr, xlabel, ylabel, sub_title in panels:
        ax = fig.add_subplot(spec)
        ax.set_facecolor(bg_color)
        extent = [ax_arr.min(), ax_arr.max(), ay_arr.min(), ay_arr.max()]
        im = ax.imshow(data, origin='lower', extent=extent, aspect='equal',
                       cmap=cmap, interpolation='bilinear',
                       vmin=0.0, vmax=global_max)
        fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
        ax.set_xlabel(xlabel, fontsize=10, color=text_color)
        ax.set_ylabel(ylabel, fontsize=10, color=text_color)
        ax.set_title(sub_title, fontsize=11, fontweight='bold', color=text_color)
        ax.tick_params(colors=text_color)
        for spine in ax.spines.values():
            spine.set_edgecolor(text_color)

    fig.suptitle(title, fontsize=14, fontweight='bold', color=text_color, y=1.02)
    if save_path:
        plt.savefig(save_path, dpi=300, bbox_inches='tight', facecolor=bg_color)
    if show:
        plt.show()
    else:
        plt.close(fig)

plots_3d

animation

Time-evolution animation exporters for 2D and 3D simulations. Requires ffmpeg for MP4 output; falls back to GIF via Pillow.

WavePacketAnimator(wf_series, times, grid, potential=None, theme='dark', log_scale=False, fps=30)

Deterministic animation pipeline. No randomness in frame generation. Global colour scale is pre-computed across all frames for consistency.

Source code in quantumlab/visualization/animation.py
def __init__(self, wf_series, times, grid, potential=None,
             theme='dark', log_scale=False, fps=30):
    self.wf_series = wf_series
    self.times = times
    self.grid = grid
    self.potential = potential
    self.theme = theme
    self.log_scale = log_scale
    self.fps = fps
    self.fig = None
    self.ax = None
    self.im = None
    self.title_obj = None

style