Skip to content

spatial_graph_algorithms.plot

Visualisation helpers for SpatialGraph objects.

Function Output Use when
plot_network 2-D graph layout Inspect a simulated graph; false edges shown in red
plot_network_3d 3-D graph layout Graphs with dim=3 positions
plot_edge_length_histogram Length distribution Check connectivity density and false-edge length
plot_comparison Side-by-side original vs reconstructed Visually judge reconstruction quality
plot_provenance_comparison Side-by-side with spatial pattern overlay Visually diagnose reconstruction quality with a user-chosen color pattern
render_simulation_visualization_bundle Saves all relevant plots One-call output for a run
export_to_blender .blend scene file High-quality 3-D Cycles rendering; requires Blender ≥ 3.6
headless_render PNG + RenderMetrics Render a .blend headlessly and capture timing/device info
check_blender bool Guard Blender-dependent cells in notebooks

API Reference

spatial_graph_algorithms.plot.plot_network(sg, *, figsize=(6.0, 4.5), dpi=100, node_size=None, edge_alpha=None, true_edge_color='#777777', false_edge_color='#d62728', false_edge_linewidth=None, true_edge_linewidth=None, false_edge_alpha=None, node_alpha=None, edge_display='auto', max_edges=None, edge_sample_seed=0, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='network.png')

Plot a 2-D spatial graph with optional false-edge highlighting.

True edges are drawn in grey; false (injected noise) edges are drawn as solid red lines when edge_metadata["is_false"] is present. When edge_display is "auto", dense graphs use adaptive styling and edge sampling to preserve readability.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph to plot. Must have 2-D positions.

required
figsize tuple of float

Figure size (width, height) in inches.

(6.0, 4.5)
node_size float

Scatter marker size. If None (default), chosen from graph size.

None
edge_alpha float

Opacity of true edges. If None (default), chosen from graph size.

None
true_edge_color str

Hex colour for true edges.

'#777777'
false_edge_color str

Hex colour for false edges.

'#d62728'
false_edge_linewidth float

Line width for false edges. If None (default), chosen from graph size.

None
true_edge_linewidth float

Line width for true edges. If None (default), chosen from graph size.

None
false_edge_alpha float

Opacity of false edges. Default is high enough to keep false edges visible above true edges, with automatic reduction when many false edges are drawn.

None
node_alpha float

Opacity of nodes. If None (default), chosen from graph size.

None
edge_display ('auto', 'all', 'sample', 'none')

Edge rendering mode. "auto" draws all edges for small/medium graphs and samples edges for large graphs. "sample" draws up to max_edges, preserving false edges before sampling true edges. "none" draws nodes only.

"auto"
max_edges int

Maximum number of edges to draw for "auto" or "sample". If omitted, a size-dependent budget is used.

None
edge_sample_seed int

Random seed used when sampling edges. Default 0.

0
save bool

If True, save the figure to output_dir / filename.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'network.png'

Returns:

Type Description
Figure

The rendered figure.

Raises:

Type Description
ValueError

If positions is None or not 2-D.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_network(
    sg: SpatialGraph,
    *,
    figsize: tuple[float, float] = (6.0, 4.5),
    dpi: int = 100,
    node_size: float | None = None,
    edge_alpha: float | None = None,
    true_edge_color: str = "#777777",
    false_edge_color: str = "#d62728",
    false_edge_linewidth: float | None = None,
    true_edge_linewidth: float | None = None,
    false_edge_alpha: float | None = None,
    node_alpha: float | None = None,
    edge_display: EdgeDisplay = "auto",
    max_edges: int | None = None,
    edge_sample_seed: int | None = 0,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "network.png",
) -> Figure:
    """Plot a 2-D spatial graph with optional false-edge highlighting.

    True edges are drawn in grey; false (injected noise) edges are drawn as
    solid red lines when ``edge_metadata["is_false"]`` is present.  When
    *edge_display* is ``"auto"``, dense graphs use adaptive styling and edge
    sampling to preserve readability.

    Parameters
    ----------
    sg : SpatialGraph
        Graph to plot.  Must have 2-D *positions*.
    figsize : tuple of float
        Figure size ``(width, height)`` in inches.
    node_size : float
        Scatter marker size.  If ``None`` (default), chosen from graph size.
    edge_alpha : float
        Opacity of true edges.  If ``None`` (default), chosen from graph size.
    true_edge_color : str
        Hex colour for true edges.
    false_edge_color : str
        Hex colour for false edges.
    false_edge_linewidth : float
        Line width for false edges.  If ``None`` (default), chosen from graph size.
    true_edge_linewidth : float
        Line width for true edges.  If ``None`` (default), chosen from graph size.
    false_edge_alpha : float, optional
        Opacity of false edges.  Default is high enough to keep false edges
        visible above true edges, with automatic reduction when many false
        edges are drawn.
    node_alpha : float, optional
        Opacity of nodes.  If ``None`` (default), chosen from graph size.
    edge_display : {"auto", "all", "sample", "none"}
        Edge rendering mode.  ``"auto"`` draws all edges for small/medium
        graphs and samples edges for large graphs.  ``"sample"`` draws up to
        *max_edges*, preserving false edges before sampling true edges.
        ``"none"`` draws nodes only.
    max_edges : int, optional
        Maximum number of edges to draw for ``"auto"`` or ``"sample"``.
        If omitted, a size-dependent budget is used.
    edge_sample_seed : int, optional
        Random seed used when sampling edges.  Default 0.
    save : bool
        If ``True``, save the figure to *output_dir* / *filename*.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Raises
    ------
    ValueError
        If *positions* is ``None`` or not 2-D.
    """
    if sg.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot plot network")
    if sg.positions.shape[1] != 2:
        raise ValueError("plot_network currently supports only 2D positions")

    edges = _extract_unique_edges(sg)
    pos = sg.positions

    fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
    false_edge_set = _false_edge_set(sg)
    style = _resolve_network_plot_style(
        sg.n_nodes,
        len(edges),
        edge_display=edge_display,
        max_edges=max_edges,
        node_size=node_size,
        edge_alpha=edge_alpha,
        true_edge_linewidth=true_edge_linewidth,
        false_edge_linewidth=false_edge_linewidth,
        false_edge_alpha=false_edge_alpha,
        node_alpha=node_alpha,
    )
    true_edges, false_edges = _split_true_false_edges(edges, false_edge_set)
    true_edges, false_edges = _sample_edges_for_display(
        true_edges,
        false_edges,
        max_edges=style.max_edges,
        seed=edge_sample_seed,
    )

    true_segments = _edge_segments(pos, true_edges)
    if len(true_segments):
        true_collection = LineCollection(
            true_segments,
            colors=true_edge_color,
            linewidths=style.true_edge_linewidth,
            alpha=style.true_edge_alpha,
            zorder=1,
        )
        true_collection.set_rasterized(style.rasterized)
        ax.add_collection(true_collection)

    false_segments = _edge_segments(pos, false_edges)
    if len(false_segments):
        resolved_false_alpha = _resolve_false_edge_alpha(len(false_edges), false_edge_alpha)
        false_collection = LineCollection(
            false_segments,
            colors=false_edge_color,
            linewidths=style.false_edge_linewidth,
            alpha=resolved_false_alpha,
            linestyles="solid",
            zorder=2,
        )
        false_collection.set_rasterized(style.rasterized)
        ax.add_collection(false_collection)

    scatter = ax.scatter(
        pos[:, 0],
        pos[:, 1],
        s=style.node_size,
        c="#1f77b4",
        alpha=style.node_alpha,
        edgecolors="none",
        zorder=3,
    )
    scatter.set_rasterized(style.rasterized)
    ax.set_title("Simulated Network")
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.set_aspect("equal", adjustable="box")

    if len(false_segments):
        from matplotlib.lines import Line2D

        handles = [
            Line2D(
                [0], [0], color=true_edge_color,
                lw=style.true_edge_linewidth, label="true edges",
            ),
            Line2D(
                [0], [0], color=false_edge_color, lw=style.false_edge_linewidth,
                alpha=_resolve_false_edge_alpha(len(false_edges), false_edge_alpha),
                linestyle="-", label="false edges",
            ),
        ]
        ax.legend(handles=handles, loc="best", frameon=False)

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_network_3d(sg, *, figsize=(6.0, 4.5), dpi=100, edge_display='auto', max_edges=None, edge_sample_seed=0, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='network_3d.png')

Plot a 3-D spatial graph using matplotlib's 3-D projection.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph to plot. Must have 3-D positions.

required
figsize tuple of float

Figure size in inches.

(6.0, 4.5)
edge_display ('auto', 'all', 'sample', 'none')

Edge rendering mode. Large graphs are sampled in "auto" mode.

"auto"
max_edges int

Maximum number of edges to draw for "auto" or "sample".

None
edge_sample_seed int

Random seed used when sampling edges. Default 0.

0
save bool

Save figure to output_dir / filename when True.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'network_3d.png'

Returns:

Type Description
Figure

The rendered figure.

Raises:

Type Description
ValueError

If positions is None or not 3-D.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_network_3d(
    sg: SpatialGraph,
    *,
    figsize: tuple[float, float] = (6.0, 4.5),
    dpi: int = 100,
    edge_display: EdgeDisplay = "auto",
    max_edges: int | None = None,
    edge_sample_seed: int | None = 0,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "network_3d.png",
) -> Figure:
    """Plot a 3-D spatial graph using matplotlib's 3-D projection.

    Parameters
    ----------
    sg : SpatialGraph
        Graph to plot.  Must have 3-D *positions*.
    figsize : tuple of float
        Figure size in inches.
    edge_display : {"auto", "all", "sample", "none"}
        Edge rendering mode.  Large graphs are sampled in ``"auto"`` mode.
    max_edges : int, optional
        Maximum number of edges to draw for ``"auto"`` or ``"sample"``.
    edge_sample_seed : int, optional
        Random seed used when sampling edges.  Default 0.
    save : bool
        Save figure to *output_dir* / *filename* when ``True``.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Raises
    ------
    ValueError
        If *positions* is ``None`` or not 3-D.
    """
    if sg.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot plot 3D network")
    if sg.positions.shape[1] != 3:
        raise ValueError("plot_network_3d requires 3D positions")

    edges = _extract_unique_edges(sg)
    pos = sg.positions
    false_edge_set = _false_edge_set(sg)
    style = _resolve_network_plot_style(
        sg.n_nodes,
        len(edges),
        edge_display=edge_display,
        max_edges=max_edges,
    )
    true_edges, false_edges = _split_true_false_edges(edges, false_edge_set)
    true_edges, false_edges = _sample_edges_for_display(
        true_edges,
        false_edges,
        max_edges=style.max_edges,
        seed=edge_sample_seed,
    )

    fig = plt.figure(figsize=figsize, dpi=dpi)
    ax = fig.add_subplot(111, projection='3d')

    for a, b in true_edges:
        xs = [pos[a, 0], pos[b, 0]]
        ys = [pos[a, 1], pos[b, 1]]
        zs = [pos[a, 2], pos[b, 2]]
        ax.plot(
            xs, ys, zs, color="#777777",
            linewidth=style.true_edge_linewidth, alpha=style.true_edge_alpha,
        )

    for a, b in false_edges:
        xs = [pos[a, 0], pos[b, 0]]
        ys = [pos[a, 1], pos[b, 1]]
        zs = [pos[a, 2], pos[b, 2]]
        ax.plot(
            xs, ys, zs, color="#d62728",
            linestyle="-", linewidth=style.false_edge_linewidth,
            alpha=_resolve_false_edge_alpha(len(false_edges), None),
        )

    ax.scatter(
        pos[:, 0], pos[:, 1], pos[:, 2],
        s=style.node_size, c='#1f77b4', alpha=style.node_alpha,
    )
    ax.set_title('Simulated 3D Network')
    ax.set_xlabel('x')
    ax.set_ylabel('y')
    ax.set_zlabel('z')

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_edge_length_histogram(sg, *, bins=30, figsize=(4.5, 3.0), dpi=100, density=False, with_all_pairs=False, max_edge_sample=50000, max_pair_sample=200000, seed=None, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='edge_length_histogram.png')

Plot the distribution of Euclidean edge lengths.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph to analyse. Requires positions.

required
bins int

Number of histogram bins.

30
figsize tuple of float

Figure size in inches.

(4.5, 3.0)
density bool

If True, normalise the histogram to a probability density.

False
with_all_pairs bool

If True, overlay a step-line showing the distance distribution for all node pairs (not just connected ones), using the same bin edges. Both curves are normalised by the total number of possible pairs C(n, 2), so the connected-pairs bars are always ≤ the reference line (they are a subset), and their sum equals the graph density E / C(n, 2). The density parameter is ignored in this mode.

False
max_edge_sample int or None

Maximum number of edges to use when computing edge lengths. When the graph has more edges than this, a random subset is drawn. None disables sampling (use all edges).

50000
max_pair_sample int

Maximum number of node-pair distances to sample when computing the all-pairs reference. For a graph with n nodes there are n(n-1)/2 pairs; above max_pair_sample a random subset is drawn. Only used when with_all_pairs is True.

200000
seed int or None

Random seed for reproducible sampling.

None
save bool

Save figure to output_dir / filename when True.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'edge_length_histogram.png'

Returns:

Type Description
Figure

The rendered figure.

Raises:

Type Description
ValueError

If positions is None or the graph has no edges.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_edge_length_histogram(
    sg: SpatialGraph,
    *,
    bins: int = 30,
    figsize: tuple[float, float] = (4.5, 3.0),
    dpi: int = 100,
    density: bool = False,
    with_all_pairs: bool = False,
    max_edge_sample: int | None = 50_000,
    max_pair_sample: int = 200_000,
    seed: int | None = None,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "edge_length_histogram.png",
) -> Figure:
    """Plot the distribution of Euclidean edge lengths.

    Parameters
    ----------
    sg : SpatialGraph
        Graph to analyse.  Requires *positions*.
    bins : int
        Number of histogram bins.
    figsize : tuple of float
        Figure size in inches.
    density : bool
        If ``True``, normalise the histogram to a probability density.
    with_all_pairs : bool
        If ``True``, overlay a step-line showing the distance distribution
        for *all* node pairs (not just connected ones), using the same bin
        edges.  Both curves are normalised by the total number of possible
        pairs C(n, 2), so the connected-pairs bars are always ≤ the
        reference line (they are a subset), and their sum equals the graph
        density E / C(n, 2).  The *density* parameter is ignored in this
        mode.
    max_edge_sample : int or None
        Maximum number of edges to use when computing edge lengths.  When the
        graph has more edges than this, a random subset is drawn.  ``None``
        disables sampling (use all edges).
    max_pair_sample : int
        Maximum number of node-pair distances to sample when computing the
        all-pairs reference.  For a graph with *n* nodes there are
        *n(n-1)/2* pairs; above *max_pair_sample* a random subset is drawn.
        Only used when *with_all_pairs* is ``True``.
    seed : int or None
        Random seed for reproducible sampling.
    save : bool
        Save figure to *output_dir* / *filename* when ``True``.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Raises
    ------
    ValueError
        If *positions* is ``None`` or the graph has no edges.
    """
    if sg.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot compute edge lengths")

    edges = _extract_unique_edges(sg)
    if len(edges) == 0:
        raise ValueError("No edges available for edge-length histogram")

    rng = np.random.default_rng(seed)
    p = sg.positions

    if max_edge_sample is not None and len(edges) > max_edge_sample:
        idx = rng.choice(len(edges), size=max_edge_sample, replace=False)
        edges = edges[idx]

    diffs = p[edges[:, 0]] - p[edges[:, 1]]
    edge_lengths = np.linalg.norm(diffs, axis=1)

    fig, ax = plt.subplots(figsize=figsize, dpi=dpi)

    if with_all_pairs:
        n = p.shape[0]
        total_pairs = n * (n - 1) // 2

        # Normalize both histograms by total_pairs so that connected pairs
        # are always ≤ the all-pairs reference (they are a subset), and the
        # sum of the connected bars equals the graph density E / C(n,2).
        edge_weights = np.ones(len(edge_lengths)) / total_pairs
        _, bin_edges, _ = ax.hist(
            edge_lengths,
            bins=bins,
            weights=edge_weights,
            color="#2ca02c",
            alpha=0.75,
            label="connected pairs",
        )

        if total_pairs <= max_pair_sample:
            i_idx, j_idx = np.triu_indices(n, k=1)
            n_sampled = total_pairs
        else:
            chosen = rng.choice(total_pairs, size=max_pair_sample, replace=False)
            i_idx, j_idx = np.triu_indices(n, k=1)
            i_idx = i_idx[chosen]
            j_idx = j_idx[chosen]
            n_sampled = max_pair_sample

        all_diffs = p[i_idx] - p[j_idx]
        all_lengths = np.linalg.norm(all_diffs, axis=1)

        # Each sampled pair represents total_pairs/n_sampled actual pairs,
        # so weight = (total_pairs/n_sampled) / total_pairs = 1/n_sampled.
        # This estimates count_in_bin / total_pairs for each bin.
        ref_counts, _ = np.histogram(
            all_lengths, bins=bin_edges,
            weights=np.ones(n_sampled) / n_sampled,
        )
        bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
        ax.step(
            bin_centers,
            ref_counts,
            where="mid",
            color="#d62728",
            linewidth=1.5,
            label="all pairs (reference)",
        )
        ax.legend(frameon=False)
        ax.set_ylabel("fraction of all possible pairs per bin")
    else:
        _, bin_edges, _ = ax.hist(
            edge_lengths,
            bins=bins,
            density=density,
            color="#2ca02c",
            alpha=0.75,
        )
        ax.set_ylabel("density" if density else "count")

    ax.set_title("Edge Length Distribution")
    ax.set_xlabel("edge length")

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_comparison(sg, *, figsize=(12.0, 4.5), dpi=100, node_size=8.0, cmap='viridis', save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='reconstruction_comparison.png')

Plot original and reconstructed positions side by side.

Nodes are coloured by their angle from the centroid in the original space, using the same colour map in both panels. Consistent colouring makes it easy to judge whether the spatial structure was recovered.

Procrustes alignment (scipy.spatial.procrustes — standardizes both arrays to unit Frobenius norm, then finds optimal rotation + reflection) is applied to the reconstructed positions before plotting so that orientation differences do not obscure reconstruction quality.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph with both positions and reconstructed_positions set.

required
figsize tuple of float

Total figure size (width, height) in inches. Default is wide enough for two side-by-side panels.

(12.0, 4.5)
node_size float

Scatter marker size.

8.0
cmap str

Matplotlib colour map name for node colouring.

'viridis'
save bool

Save figure to output_dir / filename when True.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'reconstruction_comparison.png'

Returns:

Type Description
Figure

Figure containing two subplots: original (left) and reconstructed (right).

Raises:

Type Description
ValueError

If positions or reconstructed_positions is None, or if either is not 2-D.

Source code in src/spatial_graph_algorithms/plot/network.py
def plot_comparison(
    sg: SpatialGraph,
    *,
    figsize: tuple[float, float] = (12.0, 4.5),
    dpi: int = 100,
    node_size: float = 8.0,
    cmap: str = "viridis",
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "reconstruction_comparison.png",
) -> Figure:
    """Plot original and reconstructed positions side by side.

    Nodes are coloured by their angle from the centroid in the original space,
    using the same colour map in both panels.  Consistent colouring makes it
    easy to judge whether the spatial structure was recovered.

    Procrustes alignment (``scipy.spatial.procrustes`` — standardizes both arrays to
    unit Frobenius norm, then finds optimal rotation + reflection) is applied
    to the reconstructed positions before plotting so that orientation
    differences do not obscure reconstruction quality.

    Parameters
    ----------
    sg : SpatialGraph
        Graph with both *positions* and *reconstructed_positions* set.
    figsize : tuple of float
        Total figure size ``(width, height)`` in inches.  Default is wide enough
        for two side-by-side panels.
    node_size : float
        Scatter marker size.
    cmap : str
        Matplotlib colour map name for node colouring.
    save : bool
        Save figure to *output_dir* / *filename* when ``True``.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.

    Returns
    -------
    matplotlib.figure.Figure
        Figure containing two subplots: original (left) and reconstructed
        (right).

    Raises
    ------
    ValueError
        If *positions* or *reconstructed_positions* is ``None``, or if either
        is not 2-D.
    """
    from scipy.spatial import procrustes as scipy_procrustes

    if sg.positions is None:
        raise ValueError("SpatialGraph.positions is None")
    if sg.reconstructed_positions is None:
        raise ValueError("SpatialGraph.reconstructed_positions is None — run reconstruct() first")
    if sg.positions.shape[1] > 2 or sg.reconstructed_positions.shape[1] > 2:
        raise ValueError("plot_comparison currently supports only 2D positions")

    orig = sg.positions.copy().astype(float)
    recon = sg.reconstructed_positions.copy().astype(float)

    _, recon_aligned, _ = scipy_procrustes(orig, recon)

    centroid = orig.mean(axis=0)
    angles = np.arctan2(orig[:, 1] - centroid[1], orig[:, 0] - centroid[0])
    colors = (angles - angles.min()) / ((angles.max() - angles.min()) + 1e-12)

    fig, axes = plt.subplots(1, 2, figsize=figsize, dpi=dpi)

    sc0 = axes[0].scatter(
        orig[:, 0], orig[:, 1], c=colors, cmap=cmap, s=node_size, alpha=0.85, edgecolors="none"
    )
    axes[0].set_title("Original positions")
    axes[0].set_xlabel("x")
    axes[0].set_ylabel("y")
    axes[0].set_aspect("equal", adjustable="box")

    axes[1].scatter(
        recon_aligned[:, 0], recon_aligned[:, 1],
        c=colors, cmap=cmap, s=node_size, alpha=0.85, edgecolors="none",
    )
    axes[1].set_title("Reconstructed positions")
    axes[1].set_xlabel("x")
    axes[1].set_ylabel("y")
    axes[1].set_aspect("equal", adjustable="box")

    fig.colorbar(sc0, ax=axes, label="angle from centroid (normalized)", shrink=0.8)
    fig.suptitle("Spatial Reconstruction Comparison", fontsize=12)

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_provenance_comparison(sg, *, pattern='grid', figsize=(12.0, 4.5), dpi=100, node_size=8.0, save=False, output_dir=DEFAULT_VISUALIZATION_DIR, filename='provenance_comparison.png', **pattern_kwargs)

Plot original and reconstructed positions side by side with a spatial color pattern.

A color pattern is derived from ground-truth positions and applied identically to both panels. If reconstruction is good, the pattern appears intact on the right; distortion or scrambling signals poor quality.

Procrustes alignment (scipy.spatial.procrustes — standardizes both arrays to unit Frobenius norm, then finds optimal rotation + reflection) is applied to the reconstructed positions before plotting so that orientation differences do not obscure quality. The alignment is for display only and does not mutate sg.reconstructed_positions.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph with both positions and reconstructed_positions set. Both must be exactly 2-D.

required
pattern str

Pattern kind passed to :func:spatial_graph_algorithms.plot.patterns.apply_pattern. One of "checkerboard", "grid", "rings", "quadrants", "gradient", "image". Default is "grid".

'grid'
figsize tuple of float

Total figure size (width, height) in inches.

(12.0, 4.5)
node_size float

Scatter marker size. Default 8.0.

8.0
save bool

If True, save the figure to output_dir / filename.

False
output_dir str or Path

Directory for saved output.

DEFAULT_VISUALIZATION_DIR
filename str

Filename for saved output.

'provenance_comparison.png'
**pattern_kwargs

Additional keyword arguments forwarded to :func:~spatial_graph_algorithms.plot.patterns.apply_pattern (e.g. grid_size=(4, 4), cmap="plasma").

{}

Returns:

Type Description
Figure

Figure with two subplots: original (left) and reconstructed (right).

Raises:

Type Description
ValueError

If positions or reconstructed_positions is None or not exactly 2-D.

Examples:

>>> from spatial_graph_algorithms.simulate import generate
>>> from spatial_graph_algorithms.reconstruct import reconstruct
>>> sg = generate(n=200, seed=0)
>>> sg_rec = reconstruct(sg, method="mds", seed=0)
>>> fig = plot_provenance_comparison(sg_rec, pattern="grid", grid_size=(4, 4))
Source code in src/spatial_graph_algorithms/plot/network.py
def plot_provenance_comparison(
    sg: SpatialGraph,
    *,
    pattern: str = "grid",
    figsize: tuple[float, float] = (12.0, 4.5),
    dpi: int = 100,
    node_size: float = 8.0,
    save: bool = False,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    filename: str = "provenance_comparison.png",
    **pattern_kwargs,
) -> Figure:
    """Plot original and reconstructed positions side by side with a spatial color pattern.

    A color pattern is derived from ground-truth positions and applied identically
    to both panels.  If reconstruction is good, the pattern appears intact on the
    right; distortion or scrambling signals poor quality.

    Procrustes alignment (``scipy.spatial.procrustes`` — standardizes both arrays
    to unit Frobenius norm, then finds optimal rotation + reflection) is applied to
    the reconstructed positions before plotting so that orientation differences do
    not obscure quality.  The alignment is for display only and does not mutate
    ``sg.reconstructed_positions``.

    Parameters
    ----------
    sg : SpatialGraph
        Graph with both *positions* and *reconstructed_positions* set.
        Both must be exactly 2-D.
    pattern : str
        Pattern kind passed to :func:`spatial_graph_algorithms.plot.patterns.apply_pattern`.
        One of ``"checkerboard"``, ``"grid"``, ``"rings"``, ``"quadrants"``,
        ``"gradient"``, ``"image"``.  Default is ``"grid"``.
    figsize : tuple of float
        Total figure size ``(width, height)`` in inches.
    node_size : float
        Scatter marker size.  Default 8.0.
    save : bool
        If ``True``, save the figure to *output_dir* / *filename*.
    output_dir : str or Path
        Directory for saved output.
    filename : str
        Filename for saved output.
    **pattern_kwargs
        Additional keyword arguments forwarded to
        :func:`~spatial_graph_algorithms.plot.patterns.apply_pattern`
        (e.g. ``grid_size=(4, 4)``, ``cmap="plasma"``).

    Returns
    -------
    matplotlib.figure.Figure
        Figure with two subplots: original (left) and reconstructed (right).

    Raises
    ------
    ValueError
        If *positions* or *reconstructed_positions* is ``None`` or not exactly 2-D.

    Examples
    --------
    >>> from spatial_graph_algorithms.simulate import generate
    >>> from spatial_graph_algorithms.reconstruct import reconstruct
    >>> sg = generate(n=200, seed=0)
    >>> sg_rec = reconstruct(sg, method="mds", seed=0)
    >>> fig = plot_provenance_comparison(sg_rec, pattern="grid", grid_size=(4, 4))
    """
    from scipy.spatial import procrustes as scipy_procrustes

    from .patterns import apply_pattern

    if sg.positions is None:
        raise ValueError("SpatialGraph.positions is None — cannot plot provenance comparison")
    if sg.positions.shape[1] != 2:
        raise ValueError("plot_provenance_comparison requires exactly 2-D positions")
    if sg.reconstructed_positions is None:
        raise ValueError(
            "SpatialGraph.reconstructed_positions is None — run reconstruct() first"
        )
    if sg.reconstructed_positions.shape[1] != 2:
        raise ValueError(
            "plot_provenance_comparison requires exactly 2-D reconstructed_positions"
        )

    orig = sg.positions.copy().astype(float)
    recon = sg.reconstructed_positions.copy().astype(float)
    _, recon_aligned, _ = scipy_procrustes(orig, recon)

    colors = apply_pattern(orig, kind=pattern, **pattern_kwargs)

    fig, axes = plt.subplots(1, 2, figsize=figsize, dpi=dpi)

    axes[0].scatter(
        orig[:, 0], orig[:, 1], c=colors, s=node_size, alpha=0.85, edgecolors="none"
    )
    axes[0].set_title("Original positions")
    axes[0].set_xlabel("x")
    axes[0].set_ylabel("y")
    axes[0].set_aspect("equal", adjustable="box")

    axes[1].scatter(
        recon_aligned[:, 0], recon_aligned[:, 1],
        c=colors, s=node_size, alpha=0.85, edgecolors="none",
    )
    axes[1].set_title("Reconstructed positions")
    axes[1].set_xlabel("x")
    axes[1].set_ylabel("y")
    axes[1].set_aspect("equal", adjustable="box")

    fig.suptitle(f"Provenance Comparison — pattern: {pattern}", fontsize=12)

    if save:
        _maybe_save(fig, Path(output_dir) / filename)

    return fig

spatial_graph_algorithms.plot.plot_denoising_evaluation(sg, scores, *, method, figsize=(15.0, 5.0), output_path=None)

Three-panel evaluation plot for a denoising scoring run.

Panels
  1. Score vs edge length scatter. Points coloured red for false edges and blue for true edges when is_false labels are present; grey otherwise. Pearson and Spearman correlations are annotated.
  2. ROC curve with AUC (requires is_false labels).
  3. Precision-Recall curve with AUC and optimal-F1 threshold marker (requires is_false labels).

Panels 2 and 3 degrade gracefully when ground-truth is unavailable, showing an explanatory message instead.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph used for scoring. Must have positions for panel 1; must have edge_metadata["is_false"] for panels 2 and 3.

required
scores dict[tuple[int, int], float]

Edge scores from :class:~spatial_graph_algorithms.denoise.EdgeScorer.

required
method str

Scoring method that produced scores. Score polarity is looked up in :data:spatial_graph_algorithms.denoise.SCORE_POLARITY.

required
figsize tuple of float

Figure dimensions (width, height) in inches. Default (15, 5).

(15.0, 5.0)
output_path str or Path

If given, saves the figure at 300 DPI with tight layout.

None

Returns:

Type Description
Figure

The rendered figure.

Examples:

>>> from spatial_graph_algorithms.simulate import generate
>>> from spatial_graph_algorithms.denoise import score_edges
>>> from spatial_graph_algorithms.plot.denoise import plot_denoising_evaluation
>>> sg = generate(n=200, false_edge_fraction=0.10, seed=42)
>>> scores = score_edges(sg, method="jaccard")
>>> fig = plot_denoising_evaluation(sg, scores, method="jaccard")
Source code in src/spatial_graph_algorithms/plot/denoise.py
def plot_denoising_evaluation(
    sg: SpatialGraph,
    scores: dict[tuple[int, int], float],
    *,
    method: str,
    figsize: tuple[float, float] = (15.0, 5.0),
    output_path: str | Path | None = None,
) -> Figure:
    """Three-panel evaluation plot for a denoising scoring run.

    Panels
    ------
    1. **Score vs edge length** scatter.  Points coloured red for false edges
       and blue for true edges when ``is_false`` labels are present; grey
       otherwise.  Pearson and Spearman correlations are annotated.
    2. **ROC curve** with AUC (requires ``is_false`` labels).
    3. **Precision-Recall curve** with AUC and optimal-F1 threshold marker
       (requires ``is_false`` labels).

    Panels 2 and 3 degrade gracefully when ground-truth is unavailable,
    showing an explanatory message instead.

    Parameters
    ----------
    sg : SpatialGraph
        Graph used for scoring.  Must have ``positions`` for panel 1;
        must have ``edge_metadata["is_false"]`` for panels 2 and 3.
    scores : dict[tuple[int, int], float]
        Edge scores from :class:`~spatial_graph_algorithms.denoise.EdgeScorer`.
    method : str
        Scoring method that produced *scores*.  Score polarity is looked up
        in :data:`spatial_graph_algorithms.denoise.SCORE_POLARITY`.
    figsize : tuple of float
        Figure dimensions ``(width, height)`` in inches.  Default ``(15, 5)``.
    output_path : str or Path, optional
        If given, saves the figure at 300 DPI with tight layout.

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure.

    Examples
    --------
    >>> from spatial_graph_algorithms.simulate import generate
    >>> from spatial_graph_algorithms.denoise import score_edges
    >>> from spatial_graph_algorithms.plot.denoise import plot_denoising_evaluation
    >>> sg = generate(n=200, false_edge_fraction=0.10, seed=42)
    >>> scores = score_edges(sg, method="jaccard")
    >>> fig = plot_denoising_evaluation(sg, scores, method="jaccard")
    """
    fig, axes = plt.subplots(1, 3, figsize=figsize, constrained_layout=True)
    suffix = f" — {method}" if method else ""

    edges = list(scores.keys())
    score_vals = np.array([scores[e] for e in edges])
    # Flip so that higher always means "more suspicious" for ROC/PR orientation.
    suspicious = score_vals if _score_direction(method) else -score_vals

    has_positions = sg.positions is not None
    has_gt = (
        sg.edge_metadata is not None
        and "is_false" in sg.edge_metadata.columns
        and "source" in sg.edge_metadata.columns
    )

    # ── panel 1: score vs edge length ─────────────────────────────────────
    if has_positions:
        _plot_score_vs_length(axes[0], sg, edges, score_vals, suffix, color_by_gt=has_gt)
    else:
        axes[0].text(
            0.5, 0.5, "positions not available",
            ha="center", va="center", transform=axes[0].transAxes, fontsize=11,
        )
        axes[0].set_title(f"Score vs Edge Length{suffix}")

    # ── panels 2 & 3: ROC and PR ──────────────────────────────────────────
    if has_gt:
        n64 = np.int64(sg.adjacency_matrix.shape[0])
        meta = sg.edge_metadata
        lo = np.minimum(meta["source"].values, meta["target"].values).astype(np.int64)
        hi = np.maximum(meta["source"].values, meta["target"].values).astype(np.int64)
        encoded_meta = lo * n64 + hi

        suspicious_by_code = {
            np.int64(u) * n64 + np.int64(v): s
            for (u, v), s in zip(edges, suspicious)
        }
        y_score_full = np.array(
            [suspicious_by_code.get(int(e), float("nan")) for e in encoded_meta]
        )
        y_true_full = meta["is_false"].astype(int).to_numpy()
        valid = ~np.isnan(y_score_full)
        y_true = y_true_full[valid]
        y_score = y_score_full[valid]

        if y_true.sum() > 0:
            _plot_roc(axes[1], y_true, y_score, suffix)
            _plot_pr(axes[2], y_true, y_score, suffix)
        else:
            for ax in axes[1:]:
                ax.text(0.5, 0.5, "no false edges in graph",
                        ha="center", va="center", transform=ax.transAxes, fontsize=11)
    else:
        msg = "is_false labels not available\n(real-data graph)"
        for ax, title in zip(axes[1:], [f"ROC Curve{suffix}", f"Precision-Recall{suffix}"]):
            ax.text(0.5, 0.5, msg, ha="center", va="center",
                    transform=ax.transAxes, fontsize=11)
            ax.set_title(title)

    if output_path is not None:
        out = Path(output_path)
        out.parent.mkdir(parents=True, exist_ok=True)
        fig.savefig(out, dpi=300, bbox_inches="tight")

    return fig

spatial_graph_algorithms.plot.render_simulation_visualization_bundle(sg, *, output_dir=DEFAULT_VISUALIZATION_DIR, prefix='simulation')

Save the standard visualisation bundle (network + edge-length histogram) to disk.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph to visualise.

required
output_dir str or Path

Directory where plots are written.

DEFAULT_VISUALIZATION_DIR
prefix str

Filename prefix for all saved plots.

'simulation'

Returns:

Type Description
dict

Mapping of "network" (or "network_3d" for 3-D graphs) and "edge_length_histogram" to their :class:pathlib.Path on disk.

Source code in src/spatial_graph_algorithms/plot/network.py
def render_simulation_visualization_bundle(
    sg: SpatialGraph,
    *,
    output_dir: str | Path = DEFAULT_VISUALIZATION_DIR,
    prefix: str = "simulation",
) -> dict[str, Path]:
    """Save the standard visualisation bundle (network + edge-length histogram) to disk.

    Parameters
    ----------
    sg : SpatialGraph
        Graph to visualise.
    output_dir : str or Path
        Directory where plots are written.
    prefix : str
        Filename prefix for all saved plots.

    Returns
    -------
    dict
        Mapping of ``"network"`` (or ``"network_3d"`` for 3-D graphs) and
        ``"edge_length_histogram"`` to their :class:`pathlib.Path` on disk.
    """
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)

    network_path = out / f"{prefix}_network.png"
    hist_path = out / f"{prefix}_edge_length_histogram.png"

    result = {"edge_length_histogram": hist_path}

    if sg.positions is not None and sg.positions.shape[1] == 3:
        p3 = out / f"{prefix}_network_3d.png"
        fig3 = plot_network_3d(sg, save=True, output_dir=out, filename=p3.name)
        fig2 = plot_edge_length_histogram(sg, save=True, output_dir=out, filename=hist_path.name)
        plt.close(fig3)
        plt.close(fig2)
        result['network_3d'] = p3
    else:
        fig1 = plot_network(sg, save=True, output_dir=out, filename=network_path.name)
        fig2 = plot_edge_length_histogram(sg, save=True, output_dir=out, filename=hist_path.name)
        plt.close(fig1)
        plt.close(fig2)
        result['network'] = network_path

    return result

Blender rendering

spatial_graph_algorithms.plot.export_to_blender(sg, output_path='scene.blend', *, blender_executable=None, node_radius=None, edge_radius=None, true_edge_color=(0.5, 0.5, 0.5), false_edge_color=(0.85, 0.15, 0.15), background_color=(0.12, 0.12, 0.12), node_colormap='plasma', node_color_values=None, node_color_invert=False, node_color_rgb=None, samples=128, resolution=(2560, 1440), key_light_size_factor=4.0, fill_light_ratio=0.42, world_background_strength=0.4)

Export a SpatialGraph to a Blender scene file ready for Cycles rendering.

Requires Blender ≥ 3.6 installed as a standalone application. Auto-detects the executable from common install paths, the BLENDER_PATH environment variable, or a PATH lookup. GPU is selected automatically at export time: OPTIX → CUDA → METAL → HIP → ONEAPI → CPU (silent fallback).

Node and edge radii auto-scale with graph density when not provided. Node colors default to node degree via node_colormap; pass node_color_values for any per-node scalar, or node_color_rgb for fully pre-computed (r, g, b) tuples.

Parameters:

Name Type Description Default
sg SpatialGraph

Graph to export. Must have positions set (2-D or 3-D).

required
output_path str or Path

Destination .blend file. Parent directories are created automatically.

'scene.blend'
blender_executable str or Path

Path to the Blender binary. Auto-detected when None.

None
node_radius float

Node sphere radius. Defaults to 0.10 × spacing / n^(1/dim).

None
edge_radius float

Edge tube radius. Defaults to 0.25 × node_radius.

None
true_edge_color tuple of float

RGB color for real edges, each component in [0, 1].

(0.5, 0.5, 0.5)
false_edge_color tuple of float

RGB color for false/noise edges, each component in [0, 1].

(0.85, 0.15, 0.15)
background_color tuple of float

RGB world background color.

(0.12, 0.12, 0.12)
node_colormap str

Matplotlib colormap name for coloring nodes by scalar value.

'plasma'
node_color_values ndarray

Per-node scalar array mapped through node_colormap. Defaults to node degree.

None
node_color_invert bool

Invert the colormap direction.

False
node_color_rgb list of tuple

Pre-computed (r, g, b) per node. Bypasses colormap entirely.

None
samples int

Cycles sample count (higher → less noise, longer render).

128
resolution tuple of int

Render resolution in pixels as (width, height).

(2560, 1440)
key_light_size_factor float

Key area light size as a multiple of scene radius.

4.0
fill_light_ratio float

Fill light energy as a fraction of key energy.

0.42
world_background_strength float

World ambient light contribution.

0.4

Returns:

Type Description
Path

Resolved absolute path to the saved .blend file.

Raises:

Type Description
BlenderNotFoundError

If Blender cannot be found. Install from https://www.blender.org or set the BLENDER_PATH environment variable.

BlenderExportError

If the Blender subprocess exits with a non-zero return code.

ValueError

If sg.positions is None or has unsupported dimensionality.

Source code in src/spatial_graph_algorithms/plot/blender.py
def export_to_blender(
    sg: SpatialGraph,
    output_path: str | Path = "scene.blend",
    *,
    blender_executable: str | Path | None = None,
    node_radius: float | None = None,
    edge_radius: float | None = None,
    true_edge_color: tuple[float, float, float] = (0.5, 0.5, 0.5),
    false_edge_color: tuple[float, float, float] = (0.85, 0.15, 0.15),
    background_color: tuple[float, float, float] = (0.12, 0.12, 0.12),
    node_colormap: str = "plasma",
    node_color_values: np.ndarray | None = None,
    node_color_invert: bool = False,
    node_color_rgb: list[tuple[float, float, float]] | None = None,
    samples: int = 128,
    resolution: tuple[int, int] = (2560, 1440),
    key_light_size_factor: float = 4.0,
    fill_light_ratio: float = 0.42,
    world_background_strength: float = 0.4,
) -> Path:
    """Export a SpatialGraph to a Blender scene file ready for Cycles rendering.

    Requires Blender ≥ 3.6 installed as a standalone application. Auto-detects
    the executable from common install paths, the ``BLENDER_PATH`` environment
    variable, or a PATH lookup. GPU is selected automatically at export time:
    OPTIX → CUDA → METAL → HIP → ONEAPI → CPU (silent fallback).

    Node and edge radii auto-scale with graph density when not provided.
    Node colors default to node degree via ``node_colormap``; pass
    ``node_color_values`` for any per-node scalar, or ``node_color_rgb`` for
    fully pre-computed ``(r, g, b)`` tuples.

    Parameters
    ----------
    sg : SpatialGraph
        Graph to export. Must have ``positions`` set (2-D or 3-D).
    output_path : str or Path
        Destination ``.blend`` file. Parent directories are created automatically.
    blender_executable : str or Path, optional
        Path to the Blender binary. Auto-detected when None.
    node_radius : float, optional
        Node sphere radius. Defaults to ``0.10 × spacing / n^(1/dim)``.
    edge_radius : float, optional
        Edge tube radius. Defaults to ``0.25 × node_radius``.
    true_edge_color : tuple of float
        RGB color for real edges, each component in [0, 1].
    false_edge_color : tuple of float
        RGB color for false/noise edges, each component in [0, 1].
    background_color : tuple of float
        RGB world background color.
    node_colormap : str
        Matplotlib colormap name for coloring nodes by scalar value.
    node_color_values : np.ndarray, optional
        Per-node scalar array mapped through ``node_colormap``. Defaults to node degree.
    node_color_invert : bool
        Invert the colormap direction.
    node_color_rgb : list of tuple, optional
        Pre-computed ``(r, g, b)`` per node. Bypasses colormap entirely.
    samples : int
        Cycles sample count (higher → less noise, longer render).
    resolution : tuple of int
        Render resolution in pixels as ``(width, height)``.
    key_light_size_factor : float
        Key area light size as a multiple of scene radius.
    fill_light_ratio : float
        Fill light energy as a fraction of key energy.
    world_background_strength : float
        World ambient light contribution.

    Returns
    -------
    Path
        Resolved absolute path to the saved ``.blend`` file.

    Raises
    ------
    BlenderNotFoundError
        If Blender cannot be found. Install from https://www.blender.org or set
        the ``BLENDER_PATH`` environment variable.
    BlenderExportError
        If the Blender subprocess exits with a non-zero return code.
    ValueError
        If ``sg.positions`` is None or has unsupported dimensionality.
    """
    blender = _find_blender(blender_executable)
    output_path = Path(output_path).resolve()
    output_path.parent.mkdir(parents=True, exist_ok=True)

    positions, edges, false_edges = _serialize_graph(sg)

    auto_node_r, auto_edge_r = _auto_radii(sg)
    node_radius = node_radius if node_radius is not None else auto_node_r
    edge_radius = edge_radius if edge_radius is not None else auto_edge_r

    if node_color_rgb is not None:
        if len(node_color_rgb) != len(positions):
            raise ValueError(
                "node_color_rgb must contain one RGB tuple per node; "
                f"got {len(node_color_rgb)} colors for {len(positions)} nodes"
            )
        node_colors = [tuple(float(c) for c in rgb[:3]) for rgb in node_color_rgb]
    elif node_color_values is not None:
        values = np.asarray(node_color_values, dtype=float).reshape(-1)
        if len(values) != len(positions):
            raise ValueError(
                "node_color_values must contain one scalar per node; "
                f"got {len(values)} values for {len(positions)} nodes"
            )
        node_colors = _compute_node_colors(values, node_colormap, invert=node_color_invert)
    else:
        values = np.asarray(sg.adjacency_matrix.sum(axis=1)).flatten()
        node_colors = _compute_node_colors(values, node_colormap, invert=node_color_invert)

    script = _build_scene_script(
        positions=positions,
        edges=edges,
        false_edges=false_edges,
        node_colors=node_colors,
        output_path=str(output_path),
        node_radius=node_radius,
        edge_radius=edge_radius,
        true_edge_color=true_edge_color,
        false_edge_color=false_edge_color,
        background_color=background_color,
        samples=samples,
        resolution=resolution,
        key_light_size_factor=key_light_size_factor,
        fill_light_ratio=fill_light_ratio,
        world_background_strength=world_background_strength,
    )

    tmp = tempfile.NamedTemporaryFile(
        mode="w", suffix=".py", delete=False, encoding="utf-8"
    )
    try:
        tmp.write(script)
        tmp.close()
        result = subprocess.run(
            [str(blender), "--background", "--python", tmp.name],
            capture_output=True,
        )
        if result.returncode != 0:
            raise BlenderExportError(
                f"Blender exited with code {result.returncode}:\n"
                + result.stderr.decode("utf-8", errors="replace")
            )
    finally:
        Path(tmp.name).unlink(missing_ok=True)

    return output_path

spatial_graph_algorithms.plot.headless_render(blend_path, output_stem, *, frame=1, blender_executable=None, n_nodes=0, n_edges=0, resolution=(0, 0), samples=0)

Run a headless Blender render and return timing and device metadata.

GPU preferences are re-applied at render time (they are per-user, not stored in the .blend file), so the fastest available device is always used.

Parameters:

Name Type Description Default
blend_path str or Path

Path to the .blend file produced by :func:export_to_blender.

required
output_stem str or Path

Stem for the output PNG. Blender appends the zero-padded frame number, e.g. "renders/scene""renders/scene0001.png".

required
frame int

Frame number to render (default 1).

1
blender_executable str or Path

Override path to the Blender binary; auto-detected when None.

None
n_nodes int

Graph node count to embed in :class:RenderMetrics (not used by Blender).

0
n_edges int

Graph edge count to embed in :class:RenderMetrics (not used by Blender).

0
resolution tuple of int

Render resolution to embed in :class:RenderMetrics (not used by Blender).

(0, 0)
samples int

Sample count to embed in :class:RenderMetrics (not used by Blender).

0

Returns:

Type Description
RenderMetrics

Dataclass with timing, device, and file-size information.

Raises:

Type Description
RuntimeError

If Blender exits with a non-zero code and produces no output file.

BlenderNotFoundError

If Blender cannot be located. See :func:check_blender.

Source code in src/spatial_graph_algorithms/plot/render_metrics.py
def headless_render(
    blend_path: str | Path,
    output_stem: str | Path,
    *,
    frame: int = 1,
    blender_executable: str | Path | None = None,
    n_nodes: int = 0,
    n_edges: int = 0,
    resolution: tuple[int, int] = (0, 0),
    samples: int = 0,
) -> RenderMetrics:
    """Run a headless Blender render and return timing and device metadata.

    GPU preferences are re-applied at render time (they are per-user, not stored
    in the ``.blend`` file), so the fastest available device is always used.

    Parameters
    ----------
    blend_path : str or Path
        Path to the ``.blend`` file produced by :func:`export_to_blender`.
    output_stem : str or Path
        Stem for the output PNG. Blender appends the zero-padded frame number,
        e.g. ``"renders/scene"`` → ``"renders/scene0001.png"``.
    frame : int
        Frame number to render (default 1).
    blender_executable : str or Path, optional
        Override path to the Blender binary; auto-detected when None.
    n_nodes : int
        Graph node count to embed in :class:`RenderMetrics` (not used by Blender).
    n_edges : int
        Graph edge count to embed in :class:`RenderMetrics` (not used by Blender).
    resolution : tuple of int
        Render resolution to embed in :class:`RenderMetrics` (not used by Blender).
    samples : int
        Sample count to embed in :class:`RenderMetrics` (not used by Blender).

    Returns
    -------
    RenderMetrics
        Dataclass with timing, device, and file-size information.

    Raises
    ------
    RuntimeError
        If Blender exits with a non-zero code and produces no output file.
    BlenderNotFoundError
        If Blender cannot be located. See :func:`check_blender`.
    """
    blender = _find_blender(blender_executable)
    blend_path = Path(blend_path).resolve()
    output_stem = Path(output_stem).resolve()
    output_stem.parent.mkdir(parents=True, exist_ok=True)
    output_file = Path(str(output_stem) + f"{frame:04d}.png")
    output_file.unlink(missing_ok=True)

    tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False,
                                      encoding="utf-8")
    try:
        tmp.write(_GPU_SETUP_SCRIPT)
        tmp.close()
        # --python runs before --render-frame, so GPU prefs are set before render.
        cmd = [
            str(blender), "--background", str(blend_path),
            "--python", tmp.name,
            "--render-output", str(output_stem),
            "--render-format", "PNG",
            "--render-frame", str(frame),
        ]
        t0 = time.perf_counter()
        result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
                                errors="replace")
        wall_time = time.perf_counter() - t0
    finally:
        Path(tmp.name).unlink(missing_ok=True)

    # Blender writes most output to stderr on Linux
    log = result.stdout + "\n" + result.stderr

    # "Time: MM:SS.ss (Saving: MM:SS.ss)"
    blender_time: float | None = None
    tm = re.search(r'\bTime:\s+(\d+):(\d+\.\d+)', log)
    if tm:
        blender_time = int(tm.group(1)) * 60 + float(tm.group(2))

    # Device from our injected log line: "[blender_export] Using GPU (OPTIX): ..."
    device = "CPU"
    dm = re.search(r'\[blender_export\] Using GPU \((\w+)\)', log)
    if dm:
        device = dm.group(1)

    # Blender version: "Blender 5.1.2"
    version = ""
    vm = re.search(r'Blender (\d+\.\d+\S*)', log)
    if vm:
        version = vm.group(1)

    size_kb = output_file.stat().st_size / 1024 if output_file.exists() else 0.0

    if result.returncode != 0:
        raise RuntimeError(
            f"Blender exited {result.returncode}.\n"
            + log[-2000:]
        )

    return RenderMetrics(
        blend_file=blend_path,
        output_file=output_file,
        output_size_kb=size_kb,
        wall_time_s=wall_time,
        blender_render_time_s=blender_time,
        blender_version=version,
        device=device,
        n_nodes=n_nodes,
        n_edges=n_edges,
        resolution=resolution,
        samples=samples,
    )

spatial_graph_algorithms.plot.check_blender(blender_executable=None)

Return True if a Blender executable can be located, False otherwise.

Parameters:

Name Type Description Default
blender_executable str or Path

Explicit path to the Blender binary. When None, auto-detection is used.

None

Returns:

Type Description
bool

True if Blender was found; False if it is not installed or not on PATH.

Source code in src/spatial_graph_algorithms/plot/blender.py
def check_blender(blender_executable: str | Path | None = None) -> bool:
    """Return True if a Blender executable can be located, False otherwise.

    Parameters
    ----------
    blender_executable : str or Path, optional
        Explicit path to the Blender binary. When None, auto-detection is used.

    Returns
    -------
    bool
        True if Blender was found; False if it is not installed or not on PATH.
    """
    try:
        _find_blender(blender_executable)
        return True
    except BlenderNotFoundError:
        return False

spatial_graph_algorithms.plot.RenderMetrics dataclass

Timing and hardware metadata for one Blender headless render.

Attributes:

Name Type Description
blend_file Path

Path to the .blend file that was rendered.

output_file Path

Path to the PNG output (Blender appends the frame number).

output_size_kb float

File size of the output PNG in kilobytes.

wall_time_s float

Python-measured wall-clock time in seconds (includes Blender startup).

blender_render_time_s float or None

Blender's own reported render time; None if not parsed from log output.

blender_version str

Blender version string extracted from process output.

device str

Compute device used: "CPU", "CUDA", "OPTIX", "METAL", etc.

n_nodes int

Graph node count (informational; not used by Blender itself).

n_edges int

Graph edge count (informational; not used by Blender itself).

resolution tuple of int

Render resolution as (width, height).

samples int

Cycles sample count used for the render.

Source code in src/spatial_graph_algorithms/plot/render_metrics.py
@dataclass
class RenderMetrics:
    """Timing and hardware metadata for one Blender headless render.

    Attributes
    ----------
    blend_file : Path
        Path to the ``.blend`` file that was rendered.
    output_file : Path
        Path to the PNG output (Blender appends the frame number).
    output_size_kb : float
        File size of the output PNG in kilobytes.
    wall_time_s : float
        Python-measured wall-clock time in seconds (includes Blender startup).
    blender_render_time_s : float or None
        Blender's own reported render time; None if not parsed from log output.
    blender_version : str
        Blender version string extracted from process output.
    device : str
        Compute device used: ``"CPU"``, ``"CUDA"``, ``"OPTIX"``, ``"METAL"``, etc.
    n_nodes : int
        Graph node count (informational; not used by Blender itself).
    n_edges : int
        Graph edge count (informational; not used by Blender itself).
    resolution : tuple of int
        Render resolution as ``(width, height)``.
    samples : int
        Cycles sample count used for the render.
    """

    blend_file: Path
    output_file: Path
    output_size_kb: float
    wall_time_s: float
    blender_render_time_s: float | None
    blender_version: str
    device: str
    n_nodes: int
    n_edges: int
    resolution: tuple[int, int]
    samples: int

    def summary(self) -> str:
        """Return a one-line human-readable summary of render performance."""
        t = self.blender_render_time_s or self.wall_time_s
        m, s = divmod(t, 60)
        res = f"{self.resolution[0]}×{self.resolution[1]}" if self.resolution != (0, 0) else "?"
        return (
            f"render={int(m):02d}:{s:05.2f}  wall={self.wall_time_s:.1f}s  "
            f"device={self.device}  nodes={self.n_nodes}  edges={self.n_edges}  "
            f"res={res}@{self.samples}spp  "
            f"size={self.output_size_kb:.0f}KB  "
            f"blender={self.blender_version}"
        )

    @property
    def pixels_per_second(self) -> float | None:
        """Throughput in pixels/second during the render (excludes startup)."""
        t = self.blender_render_time_s
        if t and t > 0 and self.resolution != (0, 0):
            return (self.resolution[0] * self.resolution[1]) / t
        return None

    @property
    def samples_per_second(self) -> float | None:
        """Sample-pixels per second — normalises across resolution × sample count."""
        t = self.blender_render_time_s
        if t and t > 0 and self.resolution != (0, 0) and self.samples:
            return (self.resolution[0] * self.resolution[1] * self.samples) / t
        return None

Attributes

pixels_per_second property

Throughput in pixels/second during the render (excludes startup).

samples_per_second property

Sample-pixels per second — normalises across resolution × sample count.

Methods:

summary()

Return a one-line human-readable summary of render performance.

Source code in src/spatial_graph_algorithms/plot/render_metrics.py
def summary(self) -> str:
    """Return a one-line human-readable summary of render performance."""
    t = self.blender_render_time_s or self.wall_time_s
    m, s = divmod(t, 60)
    res = f"{self.resolution[0]}×{self.resolution[1]}" if self.resolution != (0, 0) else "?"
    return (
        f"render={int(m):02d}:{s:05.2f}  wall={self.wall_time_s:.1f}s  "
        f"device={self.device}  nodes={self.n_nodes}  edges={self.n_edges}  "
        f"res={res}@{self.samples}spp  "
        f"size={self.output_size_kb:.0f}KB  "
        f"blender={self.blender_version}"
    )