Skip to content

Visualisation

Plotly-based plot helpers, theme registry, and time-series recorders. Requires the [viz] extra.

pip install "simweave[viz]"

Plot helpers

Every helper returns a plotly.graph_objects.Figure. Each accepts an optional theme= and title=.

Helper Input
plot_state_trajectories SimulationResult
plot_phase_portrait SimulationResult with ≥2 state channels
plot_queue_length QueueLengthRecorder
plot_service_utilisation ServiceUtilisationRecorder
plot_warehouse_stock WarehouseStockRecorder
plot_mc_fan MCResult, ndarray, or (times, samples)
plot_agent_path Agent (with optional graph=)

Recorders

Recorders are Entity subclasses. Snapshot at registration and at the end of every tick. Register them after the entity they observe:

qrec = sw.QueueLengthRecorder(svc)
env.register(svc)
env.register(qrec)            # ticks after svc

Themes

sw.available_themes()         # ['light', 'dark', 'presentation', 'minimal']
sw.set_default_theme("dark")

Brand themes via register_theme:

sw.register_theme(
    "edgeweave",
    template="plotly_white",
    palette=["#1c5d99", "#f49d37", "#0b132b", "#5fad56"],
    layout_overrides={"font": {"family": "Inter, system-ui"}},
)
sw.set_default_theme("edgeweave")

EdgeWeave consumption

Every figure round-trips through JSON cleanly:

import json
fig = sw.plot_state_trajectories(res)
parsed = json.loads(fig.to_json())
assert "data" in parsed and "layout" in parsed

Live output of every helper, regenerated on each docs build:

End-to-end demo

demos/14_viz_tour.py exercises every helper, writes one HTML per figure under demos/viz_out/, and produces an index.html.

API

simweave.viz -- plotly-based visualisation helpers.

Every helper returns a plotly.graph_objects.Figure so JS frontends (such as EdgeWeave) can consume the figure via fig.to_json() without losing structure. Themes are applied via a small registry so users can switch between light, dark and custom palettes without rebuilding plots.

Importing this module is cheap; plotly is only required when a plot helper is actually called. Install via the optional extra::

pip install simweave[viz]

Quick start::

from simweave.viz import (
    plot_state_trajectories,
    set_default_theme,
)
from simweave.continuous import simulate, MassSpringDamper

res = simulate(MassSpringDamper(), t_span=(0, 10), dt=0.01)
set_default_theme("dark")
fig = plot_state_trajectories(res)
fig.show()              # or fig.write_html("msd.html"), or fig.to_json()

Theme dataclass

Theme(name: str, template: str, palette: tuple[str, ...] = tuple(), layout_overrides: dict[str, Any] = dict())

A named plotly styling bundle.

QueueLengthRecorder

QueueLengthRecorder(queue: 'Queue', name: str | None = None)

Bases: _Recorder

Sample len(queue) each tick.

Attributes:

Name Type Description
times list[float]

Simulation times of each sample.

lengths list[int]

Queue length at each sample (matches times element-wise).

Source code in src/simweave/viz/recorders.py
def __init__(self, queue: "Queue", name: str | None = None) -> None:
    super().__init__(name=name or f"qlen({queue.name})")
    self.queue = queue
    self.lengths: list[int] = []

ServiceUtilisationRecorder

ServiceUtilisationRecorder(service: 'Service', name: str | None = None)

Bases: _Recorder

Sample a Service's per-channel and aggregate utilisation each tick.

The simweave :class:~simweave.discrete.services.Service keeps a monotonic busy_time per channel. We capture that snapshot and also derive an aggregate running-mean utilisation sum(busy_time) / (capacity * elapsed).

Attributes:

Name Type Description
times list[float]

Simulation times of each sample.

busy_time list[ndarray]

(n_samples, n_channels) cumulative busy time per channel.

utilisation list[float]

Running aggregate utilisation in [0, 1] at each sample.

instantaneous_busy list[ndarray]

(n_samples, n_channels) boolean array; True where the channel was busy at the moment of sampling. Useful for Gantt-style views.

Source code in src/simweave/viz/recorders.py
def __init__(self, service: "Service", name: str | None = None) -> None:
    super().__init__(name=name or f"util({service.name})")
    self.service = service
    self.busy_time: list[np.ndarray] = []
    self.utilisation: list[float] = []
    self.instantaneous_busy: list[np.ndarray] = []
    self._t0: float | None = None

WarehouseStockRecorder

WarehouseStockRecorder(warehouse: 'Warehouse', name: str | None = None)

Bases: _Recorder

Sample warehouse.inv.stock_level each tick.

Attributes:

Name Type Description
times list[float]

Simulation times of each sample.

stock list[ndarray]

(n_samples, n_skus) stock-level history.

sku_names tuple[str, ...]

Names of the SKUs (snapshot at registration; stable thereafter).

reorder_points ndarray

Reorder points snapshot at registration.

Source code in src/simweave/viz/recorders.py
def __init__(self, warehouse: "Warehouse", name: str | None = None) -> None:
    super().__init__(name=name or f"stock({warehouse.name})")
    self.warehouse = warehouse
    self.stock: list[np.ndarray] = []
    self.sku_names: tuple[str, ...] = tuple(warehouse.inv.part_names)
    self.reorder_points: np.ndarray = np.asarray(
        warehouse.inv.reorder_points, dtype=float
    ).copy()

apply_theme

apply_theme(fig: Any, theme: str | None = None) -> Any

Apply theme to fig in place and return fig.

Called by every plot helper. Safe to call again on a returned figure if the user wants to re-theme without rebuilding the data.

Source code in src/simweave/viz/themes.py
def apply_theme(fig: Any, theme: str | None = None) -> Any:
    """Apply ``theme`` to ``fig`` in place and return ``fig``.

    Called by every plot helper. Safe to call again on a returned figure
    if the user wants to re-theme without rebuilding the data.
    """
    t = get_theme(theme)
    layout: dict[str, Any] = {"template": t.template}
    if t.palette:
        layout["colorway"] = list(t.palette)
    layout.update(t.layout_overrides)
    fig.update_layout(**layout)
    return fig

available_themes

available_themes() -> tuple[str, ...]

Return the names of all currently-registered themes.

Source code in src/simweave/viz/themes.py
def available_themes() -> tuple[str, ...]:
    """Return the names of all currently-registered themes."""
    return tuple(_REGISTRY.keys())

get_default_theme

get_default_theme() -> str

Return the name of the theme used when no theme= is passed.

Source code in src/simweave/viz/themes.py
def get_default_theme() -> str:
    """Return the name of the theme used when no ``theme=`` is passed."""
    return _DEFAULT_NAME

get_theme

get_theme(name: str | None = None) -> Theme

Return the :class:Theme for name (or the current default).

Source code in src/simweave/viz/themes.py
def get_theme(name: str | None = None) -> Theme:
    """Return the :class:`Theme` for ``name`` (or the current default)."""
    key = name if name is not None else _DEFAULT_NAME
    if key not in _REGISTRY:
        raise KeyError(
            f"Unknown theme {key!r}. Available: {sorted(_REGISTRY)}."
        )
    return _REGISTRY[key]

register_theme

register_theme(name: str, template: str, palette: tuple[str, ...] | list[str] | None = None, layout_overrides: dict[str, Any] | None = None, *, overwrite: bool = False) -> None

Register a new theme.

Parameters:

Name Type Description Default
name str

Identifier used by set_default_theme(name) and theme=name.

required
template str

Name of a plotly built-in template ("plotly_white", "plotly_dark", "simple_white", "ggplot2", etc) or any template you have already added to plotly.io.templates.

required
palette tuple[str, ...] | list[str] | None

Optional sequence of hex/CSS colour strings used as the colorway for traces.

None
layout_overrides dict[str, Any] | None

Optional mapping merged into fig.layout after the template is applied. Use this for fonts, paper colours, margins, etc.

None
overwrite bool

If False (default), raises KeyError when name already exists. Pass overwrite=True to replace.

False
Source code in src/simweave/viz/themes.py
def register_theme(
    name: str,
    template: str,
    palette: tuple[str, ...] | list[str] | None = None,
    layout_overrides: dict[str, Any] | None = None,
    *,
    overwrite: bool = False,
) -> None:
    """Register a new theme.

    Parameters
    ----------
    name:
        Identifier used by ``set_default_theme(name)`` and ``theme=name``.
    template:
        Name of a plotly built-in template (``"plotly_white"``,
        ``"plotly_dark"``, ``"simple_white"``, ``"ggplot2"``, etc) or any
        template you have already added to ``plotly.io.templates``.
    palette:
        Optional sequence of hex/CSS colour strings used as the
        ``colorway`` for traces.
    layout_overrides:
        Optional mapping merged into ``fig.layout`` after the template is
        applied. Use this for fonts, paper colours, margins, etc.
    overwrite:
        If False (default), raises ``KeyError`` when ``name`` already
        exists. Pass ``overwrite=True`` to replace.
    """
    if not overwrite and name in _REGISTRY:
        raise KeyError(
            f"Theme {name!r} already registered. Pass overwrite=True to replace."
        )
    _REGISTRY[name] = Theme(
        name=name,
        template=template,
        palette=tuple(palette) if palette else (),
        layout_overrides=dict(layout_overrides or {}),
    )

set_default_theme

set_default_theme(name: str) -> None

Set the theme applied by every plot helper that omits theme=.

Source code in src/simweave/viz/themes.py
def set_default_theme(name: str) -> None:
    """Set the theme applied by every plot helper that omits ``theme=``."""
    if name not in _REGISTRY:
        raise KeyError(
            f"Unknown theme {name!r}. Available: {sorted(_REGISTRY)}. "
            "Use register_theme(...) to add a new one."
        )
    global _DEFAULT_NAME
    _DEFAULT_NAME = name

plot_agent_path

plot_agent_path(agent: Any, graph: Any | None = None, *, show_graph: bool = True, theme: str | None = None, title: str | None = None) -> Any

Render an agent's traversal over a 2-D graph.

Parameters:

Name Type Description Default
agent Any

A :class:~simweave.agents.agent.Agent (or any object exposing .history of (t, node) pairs and .name).

required
graph Any | None

Optional graph to draw underneath the path. Defaults to agent.graph. Pass show_graph=False to skip drawing it.

None
show_graph bool

If True, draws faint lines for every edge of the graph.

True
Source code in src/simweave/viz/plots.py
def plot_agent_path(
    agent: Any,
    graph: Any | None = None,
    *,
    show_graph: bool = True,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Render an agent's traversal over a 2-D graph.

    Parameters
    ----------
    agent:
        A :class:`~simweave.agents.agent.Agent` (or any object exposing
        ``.history`` of ``(t, node)`` pairs and ``.name``).
    graph:
        Optional graph to draw underneath the path. Defaults to
        ``agent.graph``. Pass ``show_graph=False`` to skip drawing it.
    show_graph:
        If True, draws faint lines for every edge of the graph.
    """
    go = _plotly.require_go()
    g = graph if graph is not None else getattr(agent, "graph", None)

    history: Iterable[tuple[float, Any]] = getattr(agent, "history", []) or []
    times: list[float] = []
    xs: list[float] = []
    ys: list[float] = []
    nodes: list[Any] = []
    for t, node in history:
        xy = _node_xy(node, g)
        if xy is None:
            continue
        times.append(float(t))
        xs.append(xy[0])
        ys.append(xy[1])
        nodes.append(node)

    fig = go.Figure()

    if show_graph and g is not None:
        edge_x: list[Any] = []
        edge_y: list[Any] = []
        edges_iter = (
            g.edges() if hasattr(g, "edges") and callable(getattr(g, "edges")) else []
        )
        for edge in edges_iter:
            # simweave Graph.edges() yields (u, v, data); networkx also (u, v) or 3-tup.
            u, v = edge[0], edge[1]
            uxy = _node_xy(u, g)
            vxy = _node_xy(v, g)
            if uxy is None or vxy is None:
                continue
            edge_x.extend([uxy[0], vxy[0], None])
            edge_y.extend([uxy[1], vxy[1], None])
        if edge_x:
            fig.add_trace(
                go.Scatter(
                    x=edge_x,
                    y=edge_y,
                    mode="lines",
                    line={"color": "rgba(150,150,150,0.4)", "width": 1},
                    hoverinfo="skip",
                    showlegend=False,
                    name="graph",
                )
            )

    if xs:
        fig.add_trace(
            go.Scatter(
                x=xs,
                y=ys,
                mode="lines+markers",
                marker={"size": 7},
                line={"width": 2},
                name=getattr(agent, "name", "agent"),
                hovertext=[f"t={tt:.2f}\n{n}" for tt, n in zip(times, nodes)],
                hoverinfo="text",
            )
        )
        fig.add_trace(
            go.Scatter(
                x=[xs[0]],
                y=[ys[0]],
                mode="markers",
                marker={"size": 12, "symbol": "circle"},
                name="start",
                showlegend=True,
            )
        )
        fig.add_trace(
            go.Scatter(
                x=[xs[-1]],
                y=[ys[-1]],
                mode="markers",
                marker={"size": 12, "symbol": "x"},
                name="end",
                showlegend=True,
            )
        )

    aname = getattr(agent, "name", "agent")
    fig.update_layout(
        title=title or f"agent path: {aname}",
        xaxis_title="x",
        yaxis_title="y",
        yaxis={"scaleanchor": "x", "scaleratio": 1},
    )
    return apply_theme(fig, theme)

plot_mc_fan

plot_mc_fan(mc_or_array: Any, times: Sequence[float] | ndarray | None = None, percentiles: Sequence[float] = (5, 25, 50, 75, 95), *, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None, show_mean: bool = True) -> Any

Percentile fan chart for a Monte Carlo trajectory ensemble.

Parameters:

Name Type Description Default
mc_or_array Any

MCResult (with 2-D .samples), raw (n_runs, n_time) ndarray, or a (times, samples) tuple.

required
times Sequence[float] | ndarray | None

Optional time axis. Ignored if mc_or_array is a tuple. Defaults to arange(n_time) if not supplied.

None
percentiles Sequence[float]

Percentiles to draw. The median (or 50th percentile if present) is rendered as a solid line; the rest form symmetric shaded bands from outside-in.

(5, 25, 50, 75, 95)
show_mean bool

If True, overlays the per-time mean as a thin dashed line.

True
Source code in src/simweave/viz/plots.py
def plot_mc_fan(
    mc_or_array: Any,
    times: Sequence[float] | np.ndarray | None = None,
    percentiles: Sequence[float] = (5, 25, 50, 75, 95),
    *,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
    show_mean: bool = True,
) -> Any:
    """Percentile fan chart for a Monte Carlo trajectory ensemble.

    Parameters
    ----------
    mc_or_array:
        ``MCResult`` (with 2-D ``.samples``), raw ``(n_runs, n_time)``
        ndarray, or a ``(times, samples)`` tuple.
    times:
        Optional time axis. Ignored if ``mc_or_array`` is a tuple.
        Defaults to ``arange(n_time)`` if not supplied.
    percentiles:
        Percentiles to draw. The median (or 50th percentile if present)
        is rendered as a solid line; the rest form symmetric shaded bands
        from outside-in.
    show_mean:
        If True, overlays the per-time mean as a thin dashed line.
    """
    go = _plotly.require_go()
    t, samples = _coerce_mc_input(mc_or_array, times)

    pct = sorted(float(p) for p in percentiles)
    if not pct:
        raise ValueError("percentiles must be non-empty.")
    qs = np.percentile(samples, pct, axis=0)  # shape (len(pct), n_time)

    # Pair outside-in for shading: (lo, hi) -> band; if odd count, the middle one
    # becomes the median line.
    bands: list[tuple[int, int]] = []
    i, j = 0, len(pct) - 1
    while i < j:
        bands.append((i, j))
        i += 1
        j -= 1
    median_idx = i if (i == j) else None

    fig = go.Figure()
    n_bands = len(bands)
    for k, (lo, hi) in enumerate(bands):
        # Outer band fades the most; inner band darkest.
        opacity = 0.15 + 0.45 * (k / max(n_bands - 1, 1))
        # Lower trace (invisible line, will be filled to)
        fig.add_trace(
            go.Scatter(
                x=t,
                y=qs[lo],
                mode="lines",
                line={"width": 0},
                showlegend=False,
                hoverinfo="skip",
            )
        )
        fig.add_trace(
            go.Scatter(
                x=t,
                y=qs[hi],
                mode="lines",
                line={"width": 0},
                fill="tonexty",
                fillcolor=f"rgba(31, 119, 180, {opacity:.3f})",
                name=f"P{pct[lo]:g}-P{pct[hi]:g}",
                hoverinfo="skip",
            )
        )
    if median_idx is not None:
        fig.add_trace(
            go.Scatter(
                x=t,
                y=qs[median_idx],
                mode="lines",
                line={"width": 2},
                name=f"P{pct[median_idx]:g} (median)",
            )
        )
    if show_mean:
        fig.add_trace(
            go.Scatter(
                x=t,
                y=samples.mean(axis=0),
                mode="lines",
                line={"dash": "dash", "width": 1.5},
                name="mean",
            )
        )

    scenario = (
        getattr(mc_or_array, "scenario_name", None)
        if not isinstance(mc_or_array, (tuple, np.ndarray))
        else None
    )
    fig.update_layout(
        title=title
        or (f"Monte Carlo fan: {scenario}" if scenario else "Monte Carlo fan"),
        xaxis_title="time",
        yaxis_title="value",
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_phase_portrait

plot_phase_portrait(result: Any, x_idx: int = 0, y_idx: int = 1, *, theme: str | None = None, title: str | None = None) -> Any

Plot state[:, x_idx] vs state[:, y_idx] with a start marker.

Source code in src/simweave/viz/plots.py
def plot_phase_portrait(
    result: Any,
    x_idx: int = 0,
    y_idx: int = 1,
    *,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Plot ``state[:, x_idx]`` vs ``state[:, y_idx]`` with a start marker."""
    go = _plotly.require_go()
    state = np.asarray(result.state)
    if state.shape[1] <= max(x_idx, y_idx):
        raise IndexError(
            f"phase portrait needs at least {max(x_idx, y_idx) + 1} state channels; "
            f"got {state.shape[1]}."
        )
    labels = list(getattr(result, "state_labels", ()) or [
        f"x{i}" for i in range(state.shape[1])
    ])
    xs = state[:, x_idx]
    ys = state[:, y_idx]

    fig = go.Figure()
    fig.add_trace(
        go.Scatter(
            x=xs,
            y=ys,
            mode="lines",
            name="trajectory",
        )
    )
    fig.add_trace(
        go.Scatter(
            x=[xs[0]],
            y=[ys[0]],
            mode="markers",
            marker={"size": 10, "symbol": "circle"},
            name="start",
        )
    )
    fig.add_trace(
        go.Scatter(
            x=[xs[-1]],
            y=[ys[-1]],
            mode="markers",
            marker={"size": 10, "symbol": "x"},
            name="end",
        )
    )
    sys_name = getattr(result, "system_name", "system")
    fig.update_layout(
        title=title or f"{sys_name} -- phase portrait",
        xaxis_title=labels[x_idx] if x_idx < len(labels) else f"x{x_idx}",
        yaxis_title=labels[y_idx] if y_idx < len(labels) else f"y{y_idx}",
    )
    return apply_theme(fig, theme)

plot_queue_length

plot_queue_length(recorder: Any, *, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None) -> Any

Step plot of queue length over time.

Parameters:

Name Type Description Default
recorder Any

A :class:~simweave.viz.recorders.QueueLengthRecorder (or anything exposing .times and .lengths).

required
time_axis 'SimTimeAxis | None'

Optional :class:~simweave.core.time_axis.SimTimeAxis. When supplied, the x-axis shows calendar dates instead of numeric ticks.

None
Source code in src/simweave/viz/plots.py
def plot_queue_length(
    recorder: Any,
    *,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Step plot of queue length over time.

    Parameters
    ----------
    recorder:
        A :class:`~simweave.viz.recorders.QueueLengthRecorder` (or anything
        exposing ``.times`` and ``.lengths``).
    time_axis:
        Optional :class:`~simweave.core.time_axis.SimTimeAxis`.  When
        supplied, the x-axis shows calendar dates instead of numeric ticks.
    """
    go = _plotly.require_go()
    times = np.asarray(recorder.times)
    lengths = np.asarray(recorder.lengths)
    qname = getattr(recorder.queue, "name", "queue") if hasattr(recorder, "queue") else "queue"

    fig = go.Figure()
    fig.add_trace(
        go.Scatter(
            x=times,
            y=lengths,
            mode="lines",
            line={"shape": "hv"},
            name=qname,
        )
    )
    fig.update_layout(
        title=title or f"queue length: {qname}",
        xaxis_title="time",
        yaxis_title="length",
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_service_utilisation

plot_service_utilisation(recorder: Any, *, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None) -> Any

Aggregate utilisation line + per-channel busy-time lines.

Parameters:

Name Type Description Default
recorder Any

A :class:~simweave.viz.recorders.ServiceUtilisationRecorder.

required
time_axis 'SimTimeAxis | None'

Optional :class:~simweave.core.time_axis.SimTimeAxis for calendar date x-axis labels.

None
Source code in src/simweave/viz/plots.py
def plot_service_utilisation(
    recorder: Any,
    *,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Aggregate utilisation line + per-channel busy-time lines.

    Parameters
    ----------
    recorder:
        A :class:`~simweave.viz.recorders.ServiceUtilisationRecorder`.
    time_axis:
        Optional :class:`~simweave.core.time_axis.SimTimeAxis` for calendar
        date x-axis labels.
    """
    go = _plotly.require_go()
    times = np.asarray(recorder.times)
    util = np.asarray(recorder.utilisation)
    busy = np.asarray(recorder.busy_time)  # (T, n_channels)
    sname = (
        getattr(recorder.service, "name", "service")
        if hasattr(recorder, "service")
        else "service"
    )

    fig = go.Figure()
    fig.add_trace(
        go.Scatter(
            x=times,
            y=util,
            mode="lines",
            name="aggregate utilisation",
            line={"width": 3},
        )
    )
    if busy.ndim == 2:
        for ch in range(busy.shape[1]):
            fig.add_trace(
                go.Scatter(
                    x=times,
                    y=busy[:, ch],
                    mode="lines",
                    name=f"channel {ch} busy_time",
                    line={"dash": "dot"},
                    yaxis="y2",
                )
            )
    fig.update_layout(
        title=title or f"service utilisation: {sname}",
        xaxis_title="time",
        yaxis={"title": "aggregate utilisation", "range": [0, 1]},
        yaxis2={
            "title": "cumulative busy time per channel",
            "overlaying": "y",
            "side": "right",
        },
        legend={"orientation": "h", "y": -0.2},
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_state_trajectories

plot_state_trajectories(result: Any, channels: Sequence[int] | None = None, *, theme: str | None = None, title: str | None = None) -> Any

Line plot of each state channel vs result.time.

Parameters:

Name Type Description Default
result Any

A :class:~simweave.continuous.solver.SimulationResult or any object exposing .time, .state (shape (T, n)) and .state_labels.

required
channels Sequence[int] | None

Optional indices of state channels to include. Default: all.

None
theme str | None

Theme name. Default: the global default (see :func:simweave.viz.set_default_theme).

None
title str | None

Optional figure title.

None
Source code in src/simweave/viz/plots.py
def plot_state_trajectories(
    result: Any,
    channels: Sequence[int] | None = None,
    *,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Line plot of each state channel vs ``result.time``.

    Parameters
    ----------
    result:
        A :class:`~simweave.continuous.solver.SimulationResult` or any
        object exposing ``.time``, ``.state`` (shape ``(T, n)``) and
        ``.state_labels``.
    channels:
        Optional indices of state channels to include. Default: all.
    theme:
        Theme name. Default: the global default (see
        :func:`simweave.viz.set_default_theme`).
    title:
        Optional figure title.
    """
    go = _plotly.require_go()
    time = np.asarray(result.time)
    state = np.asarray(result.state)
    labels = list(getattr(result, "state_labels", ()) or [
        f"x{i}" for i in range(state.shape[1])
    ])
    idxs = list(channels) if channels is not None else list(range(state.shape[1]))

    fig = go.Figure()
    for i in idxs:
        fig.add_trace(
            go.Scatter(
                x=time,
                y=state[:, i],
                mode="lines",
                name=labels[i] if i < len(labels) else f"x{i}",
            )
        )
    sys_name = getattr(result, "system_name", "system")
    fig.update_layout(
        title=title or f"{sys_name} -- state trajectories",
        xaxis_title="time",
        yaxis_title="state",
        legend_title_text="channel",
    )
    return apply_theme(fig, theme)

plot_warehouse_stock

plot_warehouse_stock(recorder: Any, sku_indices: Sequence[int] | None = None, *, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None, show_reorder_points: bool = True) -> Any

Per-SKU stock-level lines with optional reorder-point dashes.

Parameters:

Name Type Description Default
recorder Any

A :class:~simweave.viz.recorders.WarehouseStockRecorder.

required
sku_indices Sequence[int] | None

Optional iterable of SKU indices to include. Default: all.

None
time_axis 'SimTimeAxis | None'

Optional :class:~simweave.core.time_axis.SimTimeAxis for calendar date x-axis labels.

None
show_reorder_points bool

If True, draws a horizontal dashed line at each SKU's reorder point in the same colour family as its stock trace.

True
Source code in src/simweave/viz/plots.py
def plot_warehouse_stock(
    recorder: Any,
    sku_indices: Sequence[int] | None = None,
    *,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
    show_reorder_points: bool = True,
) -> Any:
    """Per-SKU stock-level lines with optional reorder-point dashes.

    Parameters
    ----------
    recorder:
        A :class:`~simweave.viz.recorders.WarehouseStockRecorder`.
    sku_indices:
        Optional iterable of SKU indices to include. Default: all.
    time_axis:
        Optional :class:`~simweave.core.time_axis.SimTimeAxis` for calendar
        date x-axis labels.
    show_reorder_points:
        If True, draws a horizontal dashed line at each SKU's reorder
        point in the same colour family as its stock trace.
    """
    go = _plotly.require_go()
    times = np.asarray(recorder.times)
    stock = np.asarray(recorder.stock)  # (T, n_skus)
    names = list(recorder.sku_names)
    rop = np.asarray(recorder.reorder_points)

    idxs = list(sku_indices) if sku_indices is not None else list(range(stock.shape[1]))

    fig = go.Figure()
    for i in idxs:
        sku = names[i] if i < len(names) else f"sku{i}"
        fig.add_trace(
            go.Scatter(
                x=times,
                y=stock[:, i],
                mode="lines",
                name=sku,
            )
        )
        if show_reorder_points and i < rop.size:
            fig.add_trace(
                go.Scatter(
                    x=[times[0], times[-1]],
                    y=[rop[i], rop[i]],
                    mode="lines",
                    name=f"{sku} reorder",
                    line={"dash": "dash", "width": 1},
                    showlegend=False,
                    hoverinfo="skip",
                )
            )
    wname = (
        getattr(recorder.warehouse, "name", "warehouse")
        if hasattr(recorder, "warehouse")
        else "warehouse"
    )
    fig.update_layout(
        title=title or f"warehouse stock: {wname}",
        xaxis_title="time",
        yaxis_title="stock level",
        legend_title_text="SKU",
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_fleet_availability

plot_fleet_availability(recorder: Any, *, normalize: bool = False, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None) -> Any

Stacked area chart of fleet operational availability over time.

The three stacked layers are (bottom to top):

  • awaiting part -- entities grounded waiting for spare stock (red).
  • in repair -- parts obtained, repair in progress (amber).
  • operational -- fully mission-capable (green).

Parameters:

Name Type Description Default
recorder Any

A :class:~simweave.reliability.fleet.FleetAvailabilityRecorder (or any object exposing .times, .operational, .in_repair, .awaiting_part, and .fleet).

required
normalize bool

If True, express each band as a fraction of fleet size (0–1) rather than an absolute vehicle count.

False
theme str | None

Theme name.

None
title str | None

Optional figure title.

None
Source code in src/simweave/viz/plots.py
def plot_fleet_availability(
    recorder: Any,
    *,
    normalize: bool = False,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Stacked area chart of fleet operational availability over time.

    The three stacked layers are (bottom to top):

    * **awaiting part** -- entities grounded waiting for spare stock (red).
    * **in repair** -- parts obtained, repair in progress (amber).
    * **operational** -- fully mission-capable (green).

    Parameters
    ----------
    recorder:
        A :class:`~simweave.reliability.fleet.FleetAvailabilityRecorder`
        (or any object exposing ``.times``, ``.operational``,
        ``.in_repair``, ``.awaiting_part``, and ``.fleet``).
    normalize:
        If ``True``, express each band as a fraction of fleet size (0–1)
        rather than an absolute vehicle count.
    theme:
        Theme name.
    title:
        Optional figure title.
    """
    go = _plotly.require_go()
    times = np.asarray(recorder.times)
    op = np.asarray(recorder.operational, dtype=float)
    ir = np.asarray(recorder.in_repair, dtype=float)
    ap = np.asarray(recorder.awaiting_part, dtype=float)

    fleet_name = getattr(getattr(recorder, "fleet", None), "name", "fleet")
    fleet_size = getattr(getattr(recorder, "fleet", None), "size", 1) or 1

    if normalize:
        op = op / fleet_size
        ir = ir / fleet_size
        ap = ap / fleet_size
        ylabel = "fraction of fleet"
    else:
        ylabel = "vehicles"

    fig = go.Figure()
    # Stack order: awaiting_part at bottom, then in_repair, then operational.
    fig.add_trace(
        go.Scatter(
            x=times,
            y=ap,
            mode="lines",
            stackgroup="one",
            name="awaiting part",
            line={"color": "rgba(180,30,30,0.9)", "width": 0.5},
            fillcolor="rgba(214,39,40,0.65)",
        )
    )
    fig.add_trace(
        go.Scatter(
            x=times,
            y=ir,
            mode="lines",
            stackgroup="one",
            name="in repair",
            line={"color": "rgba(200,100,0,0.9)", "width": 0.5},
            fillcolor="rgba(255,127,14,0.65)",
        )
    )
    fig.add_trace(
        go.Scatter(
            x=times,
            y=op,
            mode="lines",
            stackgroup="one",
            name="operational",
            line={"color": "rgba(30,140,30,0.9)", "width": 0.5},
            fillcolor="rgba(44,160,44,0.65)",
        )
    )
    fig.update_layout(
        title=title or f"fleet availability: {fleet_name}",
        xaxis_title="time",
        yaxis_title=ylabel,
        legend={"orientation": "h", "y": -0.2},
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_sensitivity_surface

plot_sensitivity_surface(result: Any, *, chart_type: str = 'surface', show_std: bool = False, theme: str | None = None, title: str | None = None) -> Any

3-D surface or grouped bar chart for a 2-D sensitivity sweep result.

Parameters:

Name Type Description Default
result Any

A :class:~simweave.reliability.sensitivity.SweepResult from a 2-D sweep (result.is_2d must be True).

required
chart_type str

"surface" (default) -- smooth 3-D surface using go.Surface. "bar" -- grouped 2-D bar chart (p1 values as groups, p2 as series), useful when parameter values are discrete labels. "heatmap" -- 2-D colour map, ideal for dense grids.

'surface'
show_std bool

If True and chart_type="bar", draws error bars representing ±1 standard deviation across Monte Carlo replicates.

False
theme str | None

Theme name.

None
title str | None

Optional figure title.

None
Source code in src/simweave/viz/plots.py
def plot_sensitivity_surface(
    result: Any,
    *,
    chart_type: str = "surface",
    show_std: bool = False,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """3-D surface or grouped bar chart for a 2-D sensitivity sweep result.

    Parameters
    ----------
    result:
        A :class:`~simweave.reliability.sensitivity.SweepResult` from a 2-D
        sweep (``result.is_2d`` must be ``True``).
    chart_type:
        ``"surface"`` (default) -- smooth 3-D surface using ``go.Surface``.
        ``"bar"`` -- grouped 2-D bar chart (p1 values as groups, p2 as
        series), useful when parameter values are discrete labels.
        ``"heatmap"`` -- 2-D colour map, ideal for dense grids.
    show_std:
        If ``True`` and ``chart_type="bar"``, draws error bars representing
        ±1 standard deviation across Monte Carlo replicates.
    theme:
        Theme name.
    title:
        Optional figure title.
    """
    go = _plotly.require_go()

    p1 = np.asarray(result.param1_values)
    metric = np.asarray(result.metric_mean)
    std = np.asarray(result.metric_std)
    p1_name = result.param1_name
    metric_name = result.metric_name

    # 1-D fallback: line plot with optional error band.
    if not result.is_2d:
        fig = go.Figure()
        fig.add_trace(
            go.Scatter(
                x=p1,
                y=metric,
                mode="lines+markers",
                name=metric_name,
                error_y=(
                    {"type": "data", "array": std.tolist(), "visible": True}
                    if show_std
                    else None
                ),
            )
        )
        fig.update_layout(
            title=title or f"sensitivity: {metric_name} vs {p1_name}",
            xaxis_title=p1_name,
            yaxis_title=metric_name,
        )
        return apply_theme(fig, theme)

    # 2-D
    p2 = np.asarray(result.param2_values)
    p2_name = result.param2_name

    if chart_type == "surface":
        fig = go.Figure(
            go.Surface(
                x=p2,
                y=p1,
                z=metric,
                colorscale="Viridis",
                colorbar={"title": metric_name},
            )
        )
        fig.update_layout(
            title=title or f"sensitivity surface: {metric_name}",
            scene={
                "xaxis_title": p2_name,
                "yaxis_title": p1_name,
                "zaxis_title": metric_name,
            },
        )

    elif chart_type == "heatmap":
        fig = go.Figure(
            go.Heatmap(
                x=[str(v) for v in p2],
                y=[str(v) for v in p1],
                z=metric,
                colorscale="Viridis",
                colorbar={"title": metric_name},
                hoverongaps=False,
            )
        )
        fig.update_layout(
            title=title or f"sensitivity heatmap: {metric_name}",
            xaxis_title=p2_name,
            yaxis_title=p1_name,
        )

    else:  # "bar"
        fig = go.Figure()
        for j, v2 in enumerate(p2):
            ey = {"type": "data", "array": std[:, j].tolist(), "visible": True} if show_std else None
            fig.add_trace(
                go.Bar(
                    name=f"{p2_name}={v2:g}",
                    x=[f"{v:g}" for v in p1],
                    y=metric[:, j],
                    error_y=ey,
                )
            )
        fig.update_layout(
            title=title or f"sensitivity: {metric_name}",
            xaxis_title=p1_name,
            yaxis_title=metric_name,
            barmode="group",
        )

    return apply_theme(fig, theme)

plot_road_occupancy

plot_road_occupancy(recorder: Any, *, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None) -> Any

Line chart of in-transit vehicle counts for a set of roads over time.

Parameters:

Name Type Description Default
recorder Any

A :class:~simweave.roads.recorder.RoadOccupancyRecorder (or any object exposing .times, .occupancy, and .road_names).

required
time_axis 'SimTimeAxis | None'

Optional :class:~simweave.core.time_axis.SimTimeAxis for calendar date x-axis labels.

None
theme str | None

Standard theme/title overrides.

None
title str | None

Standard theme/title overrides.

None
Source code in src/simweave/viz/plots.py
def plot_road_occupancy(
    recorder: Any,
    *,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Line chart of in-transit vehicle counts for a set of roads over time.

    Parameters
    ----------
    recorder:
        A :class:`~simweave.roads.recorder.RoadOccupancyRecorder` (or any
        object exposing ``.times``, ``.occupancy``, and ``.road_names``).
    time_axis:
        Optional :class:`~simweave.core.time_axis.SimTimeAxis` for calendar
        date x-axis labels.
    theme, title:
        Standard theme/title overrides.
    """
    go = _plotly.require_go()
    times = np.asarray(recorder.times)
    occ = np.asarray(recorder.occupancy)   # (n_samples, n_roads)
    road_names: Sequence[str] = recorder.road_names

    fig = go.Figure()
    for i, name in enumerate(road_names):
        col = occ[:, i] if occ.ndim == 2 else occ
        fig.add_trace(
            go.Scatter(
                x=times,
                y=col,
                mode="lines",
                name=name,
            )
        )
    fig.update_layout(
        title=title or "Road occupancy (vehicles in transit)",
        xaxis_title="time",
        yaxis_title="vehicles in transit",
        legend={"orientation": "h", "y": -0.2},
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_intersection_queues

plot_intersection_queues(recorder: Any, *, time_axis: 'SimTimeAxis | None' = None, theme: str | None = None, title: str | None = None) -> Any

Line chart of approach-queue lengths at one intersection over time.

The total queue (solid line) and each per-approach breakdown (dashed lines) are plotted.

Parameters:

Name Type Description Default
recorder Any

An :class:~simweave.roads.recorder.IntersectionQueueRecorder (or any object exposing .times, .total_queued, and .per_approach).

required
time_axis 'SimTimeAxis | None'

Standard overrides.

None
theme 'SimTimeAxis | None'

Standard overrides.

None
title 'SimTimeAxis | None'

Standard overrides.

None
Source code in src/simweave/viz/plots.py
def plot_intersection_queues(
    recorder: Any,
    *,
    time_axis: "SimTimeAxis | None" = None,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Line chart of approach-queue lengths at one intersection over time.

    The total queue (solid line) and each per-approach breakdown (dashed
    lines) are plotted.

    Parameters
    ----------
    recorder:
        An :class:`~simweave.roads.recorder.IntersectionQueueRecorder` (or
        any object exposing ``.times``, ``.total_queued``, and
        ``.per_approach``).
    time_axis, theme, title:
        Standard overrides.
    """
    go = _plotly.require_go()
    times = np.asarray(recorder.times)
    total = np.asarray(recorder.total_queued, dtype=float)

    fig = go.Figure()
    fig.add_trace(
        go.Scatter(
            x=times,
            y=total,
            mode="lines",
            name="total queued",
            line={"width": 2},
        )
    )

    # Per-approach breakdown
    if recorder.per_approach:
        approach_names = list(recorder.per_approach[0].keys())
        for road_name in approach_names:
            vals = np.asarray(
                [d.get(road_name, 0) for d in recorder.per_approach], dtype=float
            )
            fig.add_trace(
                go.Scatter(
                    x=times,
                    y=vals,
                    mode="lines",
                    name=road_name,
                    line={"dash": "dot", "width": 1},
                )
            )

    fig.update_layout(
        title=title or "Intersection queue lengths",
        xaxis_title="time",
        yaxis_title="vehicles queued",
        legend={"orientation": "h", "y": -0.2},
    )
    return apply_theme(_apply_time_axis(fig, time_axis), theme)

plot_fault_signals

plot_fault_signals(result: Any, injector: Any, channels: Sequence[int] | None = None, *, theme: str | None = None, title: str | None = None) -> Any

State trajectories with shaded regions for degraded and failed phases.

The plot overlays the simulated state channels with coloured background bands:

  • green — healthy (health index = 1).
  • amber — degrading (0 < health index < 1).
  • red — failed (health index = 0).

Parameters:

Name Type Description Default
result Any

A :class:~simweave.continuous.solver.SimulationResult from a run that used a :class:~simweave.faults.injector.FaultInjector.

required
injector Any

The :class:~simweave.faults.injector.FaultInjector used for the run (provides fault profile timings).

required
channels Sequence[int] | None

State channel indices to plot. Default: all.

None
theme str | None

Standard overrides.

None
title str | None

Standard overrides.

None
Source code in src/simweave/viz/plots.py
def plot_fault_signals(
    result: Any,
    injector: Any,
    channels: Sequence[int] | None = None,
    *,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """State trajectories with shaded regions for degraded and failed phases.

    The plot overlays the simulated state channels with coloured background
    bands:

    * **green** — healthy (health index = 1).
    * **amber** — degrading (0 < health index < 1).
    * **red** — failed (health index = 0).

    Parameters
    ----------
    result:
        A :class:`~simweave.continuous.solver.SimulationResult` from a run
        that used a :class:`~simweave.faults.injector.FaultInjector`.
    injector:
        The :class:`~simweave.faults.injector.FaultInjector` used for the run
        (provides fault profile timings).
    channels:
        State channel indices to plot.  Default: all.
    theme, title:
        Standard overrides.
    """
    go = _plotly.require_go()
    time = np.asarray(result.time)
    state = np.asarray(result.state)
    labels = list(getattr(result, "state_labels", ()) or [
        f"x{i}" for i in range(state.shape[1])
    ])
    idxs = list(channels) if channels is not None else list(range(state.shape[1]))

    fig = go.Figure()

    # Shade degradation / failure windows
    t0, tf = float(time[0]), float(time[-1])
    for f in injector.faults:
        onset = max(f.profile.onset_time, t0)
        failure = min(f.profile.failure_time, tf)
        if onset < failure:
            fig.add_vrect(
                x0=onset, x1=failure,
                fillcolor="orange", opacity=0.12, layer="below", line_width=0,
                annotation_text=f"degrading ({f.profile.mode})",
                annotation_position="top left",
            )
        if failure < tf:
            fig.add_vrect(
                x0=failure, x1=tf,
                fillcolor="red", opacity=0.12, layer="below", line_width=0,
                annotation_text="failed",
                annotation_position="top left",
            )

    for i in idxs:
        fig.add_trace(
            go.Scatter(
                x=time,
                y=state[:, i],
                mode="lines",
                name=labels[i] if i < len(labels) else f"x{i}",
            )
        )

    sys_name = getattr(result, "system_name", "system")
    fig.update_layout(
        title=title or f"{sys_name} — fault signals",
        xaxis_title="time",
        yaxis_title="state",
        legend_title_text="channel",
    )
    return apply_theme(fig, theme)

plot_health_index

plot_health_index(dataset: Any, *, show_rul: bool = True, theme: str | None = None, title: str | None = None) -> Any

Health index (and optionally RUL) over simulation time.

Parameters:

Name Type Description Default
dataset Any

A :class:~simweave.faults.dataset.FaultDataset (or any object exposing .time, .health_index, and .rul).

required
show_rul bool

If True (default), overlay remaining useful life on a secondary y-axis.

True
theme str | None

Standard overrides.

None
title str | None

Standard overrides.

None
Source code in src/simweave/viz/plots.py
def plot_health_index(
    dataset: Any,
    *,
    show_rul: bool = True,
    theme: str | None = None,
    title: str | None = None,
) -> Any:
    """Health index (and optionally RUL) over simulation time.

    Parameters
    ----------
    dataset:
        A :class:`~simweave.faults.dataset.FaultDataset` (or any object
        exposing ``.time``, ``.health_index``, and ``.rul``).
    show_rul:
        If ``True`` (default), overlay remaining useful life on a secondary
        y-axis.
    theme, title:
        Standard overrides.
    """
    go = _plotly.require_go()
    time = np.asarray(dataset.time)
    hi = np.asarray(dataset.health_index)

    fig = go.Figure()
    fig.add_trace(
        go.Scatter(
            x=time,
            y=hi,
            mode="lines",
            name="health index",
            line={"color": "royalblue", "width": 2},
        )
    )

    if show_rul:
        rul = np.asarray(dataset.rul, dtype=float)
        rul_finite = np.where(np.isinf(rul), np.nan, rul)
        if not np.all(np.isnan(rul_finite)):
            fig.add_trace(
                go.Scatter(
                    x=time,
                    y=rul_finite,
                    mode="lines",
                    name="RUL",
                    line={"color": "darkorange", "dash": "dash", "width": 1.5},
                    yaxis="y2",
                )
            )
            fig.update_layout(
                yaxis2={
                    "title": "remaining useful life",
                    "overlaying": "y",
                    "side": "right",
                    "showgrid": False,
                }
            )

    fig.update_layout(
        title=title or "Health index and RUL",
        xaxis_title="time",
        yaxis={"title": "health index", "range": [-0.05, 1.05]},
        legend={"orientation": "h", "y": -0.2},
    )
    return apply_theme(fig, theme)

plot_vehicle_metrics

plot_vehicle_metrics(result, *, model=None, theme: str | None = None, title: str | None = None)

Plot vehicle metrics with adaptive layout and automatic units.

Source code in src/simweave/viz/vehicle_dynamics.py
def plot_vehicle_metrics(
    result,
    *,
    model=None,
    theme: str | None = None,
    title: str | None = None,
):
    """Plot vehicle metrics with adaptive layout and automatic units."""

    go = _plotly.require_go()

    metrics = compute_vehicle_metrics(result, model)
    t = np.asarray(result.time)

    # --- unit helpers ---
    def accel(x): return Acceleration(x).to("m/s^2")
    def angle(x): return Angle(x).to("deg")
    def dist(x): return Distance(x).to("m")
    def force(x): return Force(x).to("N")

    # --- detect signals ---
    has_pitch = metrics["pitch"] is not None
    has_roll = metrics["roll"] is not None

    # --- build subplot structure ---
    subplot_titles = ["Body acceleration (comfort)"]

    if has_pitch or has_roll:
        subplot_titles.append("Pitch / Roll")

    subplot_titles.append("Suspension travel")
    subplot_titles.append(metrics["tyre_metric"]["name"])

    n_rows = len(subplot_titles)

    fig = make_subplots(
        rows=n_rows,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.08,
        subplot_titles=tuple(subplot_titles),
    )

    row = 1

    # --- 1. BODY ACCEL ---
    fig.add_trace(
        go.Scatter(x=t, y=accel(metrics["body_accel"]), mode="lines",
                   name="body accel [m/s²]"),
        row=row, col=1,
    )

    rms = accel(metrics["body_accel_RMS"])
    fig.add_annotation(
        text=f"RMS: {rms:.3f} m/s²",
        xref="paper", yref="paper",
        x=0.01, y=0.98,
        showarrow=False,
    )

    fig.update_yaxes(title_text="m/s²", row=row)
    row += 1

    # --- 2. PITCH / ROLL (optional) ---
    if has_pitch or has_roll:
        if has_pitch:
            fig.add_trace(
                go.Scatter(x=t, y=angle(metrics["pitch"]),
                           mode="lines", name="pitch [deg]"),
                row=row, col=1,
            )

        if has_roll:
            fig.add_trace(
                go.Scatter(x=t, y=angle(metrics["roll"]),
                           mode="lines", name="roll [deg]"),
                row=row, col=1,
            )

        fig.update_yaxes(title_text="deg", row=row)
        row += 1

    # --- 3. SUSPENSION TRAVEL ---
    for key, data in metrics["suspension_travel"].items():
        fig.add_trace(
            go.Scatter(
                x=t,
                y=dist(data),
                mode="lines",
                name=f"{key.upper()} travel [m]",
            ),
            row=row, col=1,
        )

    fig.update_yaxes(title_text="m", row=row)
    row += 1

    # --- 4. TYRE ---
    tyre_metric = metrics["tyre_metric"]

    if tyre_metric["unit"] == "N":
        conv = force
        unit_label = "N"
    else:
        conv = dist
        unit_label = "m"

    for key, data in metrics["tyre"].items():
        fig.add_trace(
            go.Scatter(
                x=t,
                y=conv(data),
                mode="lines",
                line={"dash": "dot"},
                name=f"{key.upper()} tyre [{unit_label}]",
            ),
            row=row, col=1,
        )

    fig.update_yaxes(title_text=unit_label, row=row)

    # --- layout ---
    fig.update_layout(
        title=title or "Vehicle metrics",
        xaxis_title="time",
        legend_title_text="signals",
        height=300 * n_rows,  # dynamic height
    )

    return apply_theme(fig, theme)

have_plotly

have_plotly() -> bool

Cheap probe: does the current environment have plotly available?

Source code in src/simweave/viz/_plotly.py
def have_plotly() -> bool:
    """Cheap probe: does the current environment have plotly available?"""
    try:
        import plotly  # noqa: F401
    except ImportError:
        return False
    return True