Skip to content

API reference

The public API is everything importable from planviz. Every figure function takes dark: bool = False, takes an optional ax=, returns what it drew, and saves nothing.

Style

planviz.style

Applying the Frontier matplotlib style, in light and dark.

The stylesheet ships inside the wheel (planviz/styles/frontier.mplstyle), so nothing here touches the network and no consumer has to locate the branding repository at runtime.

Importing :mod:planviz changes no global state. The style is applied only when you ask for it — either permanently with :func:use_style, or for the duration of a block with :func:style_context. Every figure function in the library draws inside a :func:style_context and additionally stamps the resolved colours onto the artists it creates, so a returned figure keeps its appearance after the context has closed.

rc_params

rc_params(dark: bool = False) -> dict[str, Any]

Return the rcParams that turn the light stylesheet into dark.

The vendored stylesheet is the light scheme — light-first is the brand's position, because papers, READMEs and notebooks all default to a light ground. Dark mode is that stylesheet plus this overlay.

>>> import planviz
>>> planviz.style.rc_params(dark=True)["axes.facecolor"]
'#1a2126'
>>> planviz.style.rc_params(dark=False)
{}
Source code in planviz/style.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def rc_params(dark: bool = False) -> dict[str, Any]:
    """Return the rcParams that turn the light stylesheet into ``dark``.

    The vendored stylesheet is the light scheme — light-first is the brand's
    position, because papers, READMEs and notebooks all default to a light
    ground. Dark mode is that stylesheet plus this overlay.

        >>> import planviz
        >>> planviz.style.rc_params(dark=True)["axes.facecolor"]
        '#1a2126'
        >>> planviz.style.rc_params(dark=False)
        {}
    """
    if not dark:
        return {}
    t = _tokens.DARK
    return {
        "figure.facecolor": t.bg,
        "figure.edgecolor": t.bg,
        "savefig.facecolor": t.bg,
        "savefig.edgecolor": t.bg,
        "axes.facecolor": t.bg_raised,
        "axes.edgecolor": t.line,
        "axes.labelcolor": t.body,
        "axes.titlecolor": t.heading,
        "grid.color": t.line,
        "xtick.color": t.muted,
        "ytick.color": t.muted,
        "xtick.labelcolor": t.muted,
        "ytick.labelcolor": t.muted,
        "text.color": t.body,
    }

use_style

use_style(dark: bool = False) -> Tokens

Apply the Frontier style to matplotlib's global rcParams; return tokens.

Use this once at the top of a notebook or a script, so figures you draw by hand match the ones this library draws. It is a deliberate, explicit call: importing planviz on its own leaves matplotlib untouched.

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> import matplotlib.pyplot as plt, planviz
>>> t = planviz.use_style(dark=True)
>>> t.mode
'dark'
>>> plt.rcParams["axes.facecolor"]
'#1a2126'
>>> plt.rcdefaults()
Source code in planviz/style.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def use_style(dark: bool = False) -> Tokens:
    """Apply the Frontier style to matplotlib's global rcParams; return tokens.

    Use this once at the top of a notebook or a script, so figures you draw by
    hand match the ones this library draws. It is a deliberate, explicit call:
    importing ``planviz`` on its own leaves matplotlib untouched.

        >>> import matplotlib
        >>> matplotlib.use("Agg")
        >>> import matplotlib.pyplot as plt, planviz
        >>> t = planviz.use_style(dark=True)
        >>> t.mode
        'dark'
        >>> plt.rcParams["axes.facecolor"]
        '#1a2126'
        >>> plt.rcdefaults()
    """
    import matplotlib.pyplot as plt

    plt.style.use(str(STYLE_PATH))
    plt.rcParams.update(rc_params(dark))
    return _tokens.get(dark)

style_context

style_context(dark: bool = False) -> Iterator[Tokens]

Apply the Frontier style for the duration of a block; yield its tokens.

rcParams are restored on exit, which is what keeps a library call from reaching into a caller's notebook.

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> import matplotlib.pyplot as plt, planviz
>>> before = plt.rcParams["axes.facecolor"]
>>> with planviz.style_context() as t:
...     figure_facecolor = plt.rcParams["figure.facecolor"]
>>> t.path
'#c2472c'
>>> plt.rcParams["axes.facecolor"] == before
True
Source code in planviz/style.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
@contextmanager
def style_context(dark: bool = False) -> Iterator[Tokens]:
    """Apply the Frontier style for the duration of a block; yield its tokens.

    rcParams are restored on exit, which is what keeps a library call from
    reaching into a caller's notebook.

        >>> import matplotlib
        >>> matplotlib.use("Agg")
        >>> import matplotlib.pyplot as plt, planviz
        >>> before = plt.rcParams["axes.facecolor"]
        >>> with planviz.style_context() as t:
        ...     figure_facecolor = plt.rcParams["figure.facecolor"]
        >>> t.path
        '#c2472c'
        >>> plt.rcParams["axes.facecolor"] == before
        True
    """
    import matplotlib.pyplot as plt

    with plt.style.context(str(STYLE_PATH)):
        with plt.rc_context(rc_params(dark)):
            yield _tokens.get(dark)

Tokens

planviz.tokens

Frontier design tokens — the palette every planviz figure draws in.

Generated by scripts/sync_tokens.py from the openplan-labs/branding repository (tokens/tokens.json). Do not edit by hand: CI regenerates this file and fails the build if it has drifted from the brand.

The three semantic values are the figure legend, not a chart palette. path is the solution and the only warm colour in the system; frontier is the open list; expanded is the closed list. A fourth or fifth series in a chart takes :data:AGENT_RAMP, never one of those three.

>>> from planviz import tokens
>>> tokens.LIGHT.path
'#c2472c'
>>> tokens.get(dark=True).path
'#e87a5c'
>>> tokens.LIGHT.agent(2)
'#7a6f9c'

Tokens dataclass

One resolved colour scheme — light or dark.

Every field is a CSS/matplotlib colour string. Prefer the semantic names over literals: a figure drawn in these values is on-brand by construction.

>>> from planviz import tokens
>>> t = tokens.get(dark=False)
>>> t.mode, t.path
('light', '#c2472c')
Source code in planviz/tokens.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
@dataclass(frozen=True)
class Tokens:
    """One resolved colour scheme — light or dark.

    Every field is a CSS/matplotlib colour string. Prefer the semantic names
    over literals: a figure drawn in these values is on-brand by construction.

        >>> from planviz import tokens
        >>> t = tokens.get(dark=False)
        >>> t.mode, t.path
        ('light', '#c2472c')
    """

    #: ``"light"`` or ``"dark"``.
    mode: str
    #: Page ground. Cool, not cream.
    bg: str
    #: Cards and figure panels — the axes face.
    bg_raised: str
    #: Rules, borders, and obstacles: structure, not data.
    line: str
    #: Ink for titles.
    heading: str
    #: Running text and axis labels.
    body: str
    #: Captions and tick labels.
    muted: str
    #: Unvisited nodes. Not a prose colour.
    faint: str
    #: **The solution.** The only warm value in the system.
    path: str
    #: Translucent wash of :attr:`path`, for highlighted regions.
    path_soft: str
    #: **The open list.**
    frontier: str
    #: **The closed list.** Not a text colour on light (3.60:1).
    expanded: str
    #: Categorical ramp, shared by both modes.
    agents: tuple[str, ...] = AGENT_RAMP

    def agent(self, index: int) -> str:
        """Return the ramp colour for agent ``index``, wrapping past eight.

            >>> from planviz import tokens
            >>> tokens.LIGHT.agent(0) == tokens.AGENT_RAMP[0]
            True
        """
        return self.agents[index % len(self.agents)]

    def marker(self, index: int) -> str:
        """Return the marker for agent ``index`` — the shape channel.

        Changes only once the hue has wrapped, so agent 8 shares agent 0's
        colour but not its mark.

            >>> from planviz import tokens
            >>> tokens.LIGHT.marker(0), tokens.LIGHT.marker(8)
            ('o', 's')
        """
        return MARKERS[(index // len(self.agents)) % len(MARKERS)]

    def agent_colors(self, names: Sequence[str]) -> dict:
        """Map ``names`` to ramp colours by position — stable across re-renders.

            >>> from planviz import tokens
            >>> tokens.LIGHT.agent_colors(["a", "b"])["a"] == tokens.AGENT_RAMP[0]
            True
        """
        return {name: self.agent(i) for i, name in enumerate(names)}

    def sequential(self, name: str = "frontier_seq"):
        """Return the single-hue magnitude colormap: ``expanded`` → ``path``.

        Warm end is expensive, which agrees with the rest of the system. Never
        a rainbow.

            >>> from planviz import tokens
            >>> tokens.LIGHT.sequential().N
            256
        """
        from matplotlib.colors import LinearSegmentedColormap

        return LinearSegmentedColormap.from_list(
            name, [self.expanded, self.path], N=256
        )

agent

agent(index: int) -> str

Return the ramp colour for agent index, wrapping past eight.

from planviz import tokens tokens.LIGHT.agent(0) == tokens.AGENT_RAMP[0] True

Source code in planviz/tokens.py
116
117
118
119
120
121
122
123
def agent(self, index: int) -> str:
    """Return the ramp colour for agent ``index``, wrapping past eight.

        >>> from planviz import tokens
        >>> tokens.LIGHT.agent(0) == tokens.AGENT_RAMP[0]
        True
    """
    return self.agents[index % len(self.agents)]

marker

marker(index: int) -> str

Return the marker for agent index — the shape channel.

Changes only once the hue has wrapped, so agent 8 shares agent 0's colour but not its mark.

>>> from planviz import tokens
>>> tokens.LIGHT.marker(0), tokens.LIGHT.marker(8)
('o', 's')
Source code in planviz/tokens.py
125
126
127
128
129
130
131
132
133
134
135
def marker(self, index: int) -> str:
    """Return the marker for agent ``index`` — the shape channel.

    Changes only once the hue has wrapped, so agent 8 shares agent 0's
    colour but not its mark.

        >>> from planviz import tokens
        >>> tokens.LIGHT.marker(0), tokens.LIGHT.marker(8)
        ('o', 's')
    """
    return MARKERS[(index // len(self.agents)) % len(MARKERS)]

agent_colors

agent_colors(names: Sequence[str]) -> dict

Map names to ramp colours by position — stable across re-renders.

from planviz import tokens tokens.LIGHT.agent_colors(["a", "b"])["a"] == tokens.AGENT_RAMP[0] True

Source code in planviz/tokens.py
137
138
139
140
141
142
143
144
def agent_colors(self, names: Sequence[str]) -> dict:
    """Map ``names`` to ramp colours by position — stable across re-renders.

        >>> from planviz import tokens
        >>> tokens.LIGHT.agent_colors(["a", "b"])["a"] == tokens.AGENT_RAMP[0]
        True
    """
    return {name: self.agent(i) for i, name in enumerate(names)}

sequential

sequential(name: str = 'frontier_seq')

Return the single-hue magnitude colormap: expandedpath.

Warm end is expensive, which agrees with the rest of the system. Never a rainbow.

>>> from planviz import tokens
>>> tokens.LIGHT.sequential().N
256
Source code in planviz/tokens.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def sequential(self, name: str = "frontier_seq"):
    """Return the single-hue magnitude colormap: ``expanded`` → ``path``.

    Warm end is expensive, which agrees with the rest of the system. Never
    a rainbow.

        >>> from planviz import tokens
        >>> tokens.LIGHT.sequential().N
        256
    """
    from matplotlib.colors import LinearSegmentedColormap

    return LinearSegmentedColormap.from_list(
        name, [self.expanded, self.path], N=256
    )

get

get(dark: bool = False) -> Tokens

Return :data:DARK when dark is true, else :data:LIGHT.

from planviz import tokens tokens.get(dark=True).mode 'dark'

Source code in planviz/tokens.py
209
210
211
212
213
214
215
216
def get(dark: bool = False) -> Tokens:
    """Return :data:`DARK` when ``dark`` is true, else :data:`LIGHT`.

        >>> from planviz import tokens
        >>> tokens.get(dark=True).mode
        'dark'
    """
    return DARK if dark else LIGHT

Grids and agents

planviz.grids

Grid maps, multi-agent routes, and the canonical search figure.

Three pictures, in the order a reader meets them:

  • :func:draw_grid — the problem. Obstacles are structure, so they take line, the same value as a table rule.
  • :func:draw_search — what the search did. The three-mark legend (filled dot, hollow ring, connected stroke) is the brand's canonical figure and the one most worth copying.
  • :func:draw_paths — what it returned, for many agents at once.
  • :func:draw_heatmap — a magnitude per cell: congestion, visit counts, cost.

Grids are indexed [row][col] with row 0 at the top, and cells are drawn at integer coordinates, so (0, 0) is the top-left cell centre. Nothing here knows about pymapf, cuplan or jupyddl types: a grid is any 2-D array-like where truthy means blocked, and a path is any sequence of (row, col) pairs.

draw_heatmap

draw_heatmap(values: Any, grid: Any = None, *, ax: Axes | None = None, dark: bool = False, label: str | None = None, colorbar: bool = True, vmax: float | None = None, title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Shade each cell of a grid by a magnitude — congestion, visits, cost.

The ramp is the single-hue expandedpath sequential map: warm is expensive, which is the same claim the accent makes everywhere else in the system. Never a rainbow, and never a diverging map for a quantity that has no meaningful midpoint.

Blocked cells are drawn as obstacles, not as zero. A cell no agent could enter and a cell no agent chose to enter are different facts.

Parameters:

Name Type Description Default
values Any

2-D array-like of magnitudes, same shape as grid.

required
grid Any

occupancy grid; blocked cells are masked out of the ramp.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
label str | None

colourbar label — say the unit.

None
colorbar bool

draw the colourbar.

True
vmax float | None

top of the ramp; defaults to the largest value present.

None
title str | None

axes title.

None
figsize tuple[float, float] | None

overrides the size derived from the grid's aspect.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.draw_heatmap( ... [[0, 2, 0], [1, 5, 1], [0, 3, 0]], ... grid=[[0, 0, 0], [0, 0, 0], [1, 0, 0]], ... label="agent-timesteps", ... ) len(ax.images) 1

Source code in planviz/grids.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def draw_heatmap(
    values: Any,
    grid: Any = None,
    *,
    ax: Axes | None = None,
    dark: bool = False,
    label: str | None = None,
    colorbar: bool = True,
    vmax: float | None = None,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Shade each cell of a grid by a magnitude — congestion, visits, cost.

    The ramp is the single-hue ``expanded`` → ``path`` sequential map: warm is
    expensive, which is the same claim the accent makes everywhere else in the
    system. Never a rainbow, and never a diverging map for a quantity that has
    no meaningful midpoint.

    Blocked cells are drawn as obstacles, not as zero. A cell no agent could
    enter and a cell no agent chose to enter are different facts.

    Args:
        values: 2-D array-like of magnitudes, same shape as ``grid``.
        grid: occupancy grid; blocked cells are masked out of the ramp.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        label: colourbar label — say the unit.
        colorbar: draw the colourbar.
        vmax: top of the ramp; defaults to the largest value present.
        title: axes title.
        figsize: overrides the size derived from the grid's aspect.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.draw_heatmap(
        ...     [[0, 2, 0], [1, 5, 1], [0, 3, 0]],
        ...     grid=[[0, 0, 0], [0, 0, 0], [1, 0, 0]],
        ...     label="agent-timesteps",
        ... )
        >>> len(ax.images)
        1
    """
    magnitudes = np.asarray(values, dtype=float)
    if magnitudes.ndim != 2:
        raise ValueError("values must be 2-D indexed [row][col]")

    with style_context(dark) as tokens:
        _, ax = _map_axes(ax, tokens, magnitudes.shape, figsize)
        blocked = (
            _common.occupancy(grid)
            if grid is not None
            else np.zeros(magnitudes.shape, dtype=bool)
        )
        cmap = tokens.sequential().copy()
        cmap.set_bad(tokens.line)
        peak = float(vmax if vmax is not None else np.nanmax(magnitudes)) or 1.0
        image = ax.imshow(
            np.ma.masked_where(blocked, magnitudes),
            cmap=cmap,
            vmin=0,
            vmax=peak,
            extent=_extent(magnitudes.shape),
            interpolation="nearest",
            zorder=1,
        )
        if colorbar:
            bar = ax.figure.colorbar(image, ax=ax, fraction=0.046, pad=0.03)
            bar.outline.set_visible(False)
            bar.ax.tick_params(colors=tokens.muted, labelsize=8)
            if label:
                bar.set_label(label, color=tokens.body, fontsize=9)
        _title(ax, tokens, title)
    return ax

draw_grid

draw_grid(grid: Any = None, *, shape: tuple[int, int] | None = None, ax: Axes | None = None, dark: bool = False, title: str | None = None, lattice: bool = False, figsize: tuple[float, float] | None = None) -> Axes

Draw an occupancy grid: blocked cells filled, nothing else.

No lattice is drawn by default. The cells are the grid, and a lattice under an occupancy map doubles the line count for no information — see brand/figures.md. Pass lattice=True for a small teaching figure where the cell boundaries are the point.

Parameters:

Name Type Description Default
grid Any

2-D array-like indexed [row][col]; truthy means blocked. May be omitted if shape is given.

None
shape tuple[int, int] | None

(height, width) for an empty grid.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
title str | None

axes title.

None
lattice bool

draw hairline cell boundaries.

False
figsize tuple[float, float] | None

overrides the size derived from the grid's aspect.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.draw_grid([[0, 0, 1], [0, 1, 0], [0, 0, 0]]) tuple(float(v) for v in ax.get_xlim()) (-0.5, 2.5)

Source code in planviz/grids.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def draw_grid(
    grid: Any = None,
    *,
    shape: tuple[int, int] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    title: str | None = None,
    lattice: bool = False,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Draw an occupancy grid: blocked cells filled, nothing else.

    No lattice is drawn by default. The cells *are* the grid, and a lattice
    under an occupancy map doubles the line count for no information — see
    ``brand/figures.md``. Pass ``lattice=True`` for a small teaching figure
    where the cell boundaries are the point.

    Args:
        grid: 2-D array-like indexed ``[row][col]``; truthy means blocked.
            May be omitted if ``shape`` is given.
        shape: ``(height, width)`` for an empty grid.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        title: axes title.
        lattice: draw hairline cell boundaries.
        figsize: overrides the size derived from the grid's aspect.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.draw_grid([[0, 0, 1], [0, 1, 0], [0, 0, 0]])
        >>> tuple(float(v) for v in ax.get_xlim())
        (-0.5, 2.5)
    """
    with style_context(dark) as tokens:
        resolved = _shape_of(grid, None, shape)
        _, ax = _map_axes(ax, tokens, resolved, figsize)
        if grid is not None:
            _draw_obstacles(ax, grid, tokens)
        if lattice:
            height, width = resolved
            for col in range(width + 1):
                ax.axvline(col - 0.5, color=tokens.line, linewidth=0.6, zorder=0.5)
            for row in range(height + 1):
                ax.axhline(row - 0.5, color=tokens.line, linewidth=0.6, zorder=0.5)
        _title(ax, tokens, title)
    return ax

draw_paths

draw_paths(paths: Any, grid: Any = None, *, shape: tuple[int, int] | None = None, ax: Axes | None = None, dark: bool = False, highlight: str | None = None, labels: bool = True, offsets: bool = True, endpoints: bool = True, title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Draw multi-agent routes over a grid — one stroke per agent.

Colour comes from the agent ramp by stable index, so re-rendering with a different agent order does not reshuffle the figure. Three rules from brand/figures.md are enforced here:

  • past eight agents, individual hues stop being readable, so the figure switches to one colour at reduced opacity;
  • when highlight names an agent, that agent takes path — the solution accent — and every other agent drops to faint;
  • each route starts on a hollow ring and ends on a filled disc, so direction is carried without arrowheads.

Parameters:

Name Type Description Default
paths Any

{name: [(row, col), ...]}, or a sequence of paths that will be named by index.

required
grid Any

optional occupancy grid to draw underneath.

None
shape tuple[int, int] | None

(height, width) when there is no grid.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
highlight str | None

name of the one agent this figure is about.

None
labels bool

annotate each route with its agent name.

True
offsets bool

draw routes on slightly offset rails so a shared corridor still shows how many agents are in it.

True
endpoints bool

draw the start ring and goal disc.

True
title str | None

axes title.

None
figsize tuple[float, float] | None

overrides the size derived from the grid's aspect.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.draw_paths( ... {"a": [(0, 0), (0, 1), (1, 1)], "b": [(2, 0), (1, 0), (1, 1)]}, ... shape=(3, 3), ... ) len(ax.lines) > 0 True

Source code in planviz/grids.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
def draw_paths(
    paths: Any,
    grid: Any = None,
    *,
    shape: tuple[int, int] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    highlight: str | None = None,
    labels: bool = True,
    offsets: bool = True,
    endpoints: bool = True,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Draw multi-agent routes over a grid — one stroke per agent.

    Colour comes from the agent ramp by **stable index**, so re-rendering with
    a different agent order does not reshuffle the figure. Three rules from
    ``brand/figures.md`` are enforced here:

    * past eight agents, individual hues stop being readable, so the figure
      switches to one colour at reduced opacity;
    * when ``highlight`` names an agent, that agent takes ``path`` — the
      solution accent — and every other agent drops to ``faint``;
    * each route starts on a hollow ring and ends on a filled disc, so
      direction is carried without arrowheads.

    Args:
        paths: ``{name: [(row, col), ...]}``, or a sequence of paths that will
            be named by index.
        grid: optional occupancy grid to draw underneath.
        shape: ``(height, width)`` when there is no grid.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        highlight: name of the one agent this figure is about.
        labels: annotate each route with its agent name.
        offsets: draw routes on slightly offset rails so a shared corridor
            still shows how many agents are in it.
        endpoints: draw the start ring and goal disc.
        title: axes title.
        figsize: overrides the size derived from the grid's aspect.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.draw_paths(
        ...     {"a": [(0, 0), (0, 1), (1, 1)], "b": [(2, 0), (1, 0), (1, 1)]},
        ...     shape=(3, 3),
        ... )
        >>> len(ax.lines) > 0
        True
    """
    routes = _common.as_paths(paths)
    with style_context(dark) as tokens:
        resolved = _shape_of(grid, routes, shape)
        _, ax = _map_axes(ax, tokens, resolved, figsize)
        if grid is not None:
            _draw_obstacles(ax, grid, tokens)

        names = list(routes)
        crowded = len(names) > CROWD and highlight is None
        if highlight is not None:
            colors = {name: tokens.faint for name in names}
            colors[highlight] = tokens.path
        elif crowded:
            colors = {name: tokens.expanded for name in names}
        else:
            colors = _common.series_colors(names, tokens)
        alpha = 0.55 if crowded else 0.95

        for index, name in enumerate(names):
            path = routes[name]
            shift = _common.rails(len(names), index) if offsets else 0.0
            xs = [cell[1] + shift for cell in path]
            ys = [cell[0] + shift for cell in path]
            color = colors[name]
            top = 4 if (highlight is None or name == highlight) else 3
            if not crowded:
                # A wide panel-coloured underlay keeps crossing rails legible.
                ax.plot(
                    xs, ys, color=tokens.bg_raised, linewidth=4.5,
                    solid_capstyle="round", zorder=top - 0.5,
                )
            ax.plot(
                xs, ys, color=color, alpha=alpha, zorder=top,
                label=None if crowded else name, **MARKS["path"],
            )
            if endpoints and not crowded:
                ax.plot(
                    xs[0], ys[0], markeredgecolor=color, zorder=top + 1,
                    **MARKS["start"],
                )
                ax.plot(
                    xs[-1], ys[-1], color=color,
                    markeredgecolor=tokens.bg_raised, markeredgewidth=1.0,
                    zorder=top + 1, **MARKS["goal"],
                )
            # With one agent as the subject, labelling the context agents
            # just adds ink to the thing the figure is deliberately quieting.
            named = labels and not crowded and (
                highlight is None or name == highlight
            )
            if named:
                ax.annotate(
                    name,
                    (xs[0], ys[0]),
                    textcoords="offset points",
                    xytext=(0, 9),
                    ha="center",
                    fontsize=8,
                    color=tokens.muted,
                    zorder=6,
                )
        if crowded:
            _common.caption(
                ax,
                tokens,
                f"{len(names)} agents — drawn by density, not by colour",
                y=-0.04,
            )
        _title(ax, tokens, title)
    return ax
draw_search(expanded: Iterable | None = None, frontier: Iterable | None = None, path: Sequence | None = None, *, grid: Any = None, shape: tuple[int, int] | None = None, start: tuple[int, int] | None = None, goal: tuple[int, int] | None = None, unvisited: bool = True, ax: Axes | None = None, dark: bool = False, legend: bool = True, title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Draw the three sets of a search: closed list, open list, and solution.

This is the organisation's canonical figure. The three sets differ by shape as well as hue — small filled dot, hollow ring, connected stroke — which is the channel that survives greyscale printing and red/green colour blindness. The path is drawn last and is the only continuous element.

One idea per figure: this draws the search and its result at full strength, which is right for a legend plate and for the final frame of an animation. For a figure about expansion order alone, pass path=None.

Parameters:

Name Type Description Default
expanded Iterable | None

cells in the closed list, as (row, col) pairs.

None
frontier Iterable | None

cells in the open list.

None
path Sequence | None

the returned solution, in order.

None
grid Any

optional occupancy grid to draw underneath.

None
shape tuple[int, int] | None

(height, width) when there is no grid.

None
start tuple[int, int] | None

start cell — a hollow ring. Defaults to the path's first cell.

None
goal tuple[int, int] | None

goal cell — a filled disc. Defaults to the path's last cell.

None
unvisited bool

stipple the never-touched free cells in faint.

True
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
legend bool

draw the three-mark legend. Worth keeping the first time a reader meets the figure; drop it once they know the mapping.

True
title str | None

axes title.

None
figsize tuple[float, float] | None

overrides the size derived from the grid's aspect.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.draw_search( ... expanded=[(0, 0), (0, 1), (1, 0)], ... frontier=[(1, 1), (2, 0)], ... path=[(0, 0), (1, 0), (2, 0)], ... shape=(3, 3), ... ) ax.get_title() ''

Source code in planviz/grids.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
def draw_search(
    expanded: Iterable | None = None,
    frontier: Iterable | None = None,
    path: Sequence | None = None,
    *,
    grid: Any = None,
    shape: tuple[int, int] | None = None,
    start: tuple[int, int] | None = None,
    goal: tuple[int, int] | None = None,
    unvisited: bool = True,
    ax: Axes | None = None,
    dark: bool = False,
    legend: bool = True,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Draw the three sets of a search: closed list, open list, and solution.

    This is the organisation's canonical figure. The three sets differ by
    **shape** as well as hue — small filled dot, hollow ring, connected stroke —
    which is the channel that survives greyscale printing and red/green colour
    blindness. The path is drawn last and is the only continuous element.

    One idea per figure: this draws the search *and* its result at full
    strength, which is right for a legend plate and for the final frame of an
    animation. For a figure about expansion order alone, pass ``path=None``.

    Args:
        expanded: cells in the closed list, as ``(row, col)`` pairs.
        frontier: cells in the open list.
        path: the returned solution, in order.
        grid: optional occupancy grid to draw underneath.
        shape: ``(height, width)`` when there is no grid.
        start: start cell — a hollow ring. Defaults to the path's first cell.
        goal: goal cell — a filled disc. Defaults to the path's last cell.
        unvisited: stipple the never-touched free cells in ``faint``.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        legend: draw the three-mark legend. Worth keeping the first time a
            reader meets the figure; drop it once they know the mapping.
        title: axes title.
        figsize: overrides the size derived from the grid's aspect.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.draw_search(
        ...     expanded=[(0, 0), (0, 1), (1, 0)],
        ...     frontier=[(1, 1), (2, 0)],
        ...     path=[(0, 0), (1, 0), (2, 0)],
        ...     shape=(3, 3),
        ... )
        >>> ax.get_title()
        ''
    """
    closed = _cells(expanded)
    open_list = _cells(frontier)
    solution = _cells(path)

    with style_context(dark) as tokens:
        pool = {"grid": grid, "shape": shape}
        if shape is None and grid is None:
            stacked = np.vstack([closed, open_list, solution])
            if stacked.size == 0:
                raise ValueError("nothing to draw: pass a grid, a shape, or cells")
            pool["shape"] = (
                int(stacked[:, 0].max()) + 1,
                int(stacked[:, 1].max()) + 1,
            )
        resolved = _shape_of(pool["grid"], None, pool["shape"])
        _, ax = _map_axes(ax, tokens, resolved, figsize)
        if grid is not None:
            _draw_obstacles(ax, grid, tokens)

        if unvisited:
            height, width = resolved
            blocked = (
                _common.occupancy(grid)
                if grid is not None
                else np.zeros(resolved, dtype=bool)
            )
            touched = np.zeros(resolved, dtype=bool)
            for cells in (closed, open_list, solution):
                for row, col in cells.astype(int):
                    if 0 <= row < height and 0 <= col < width:
                        touched[row, col] = True
            rows, cols = np.nonzero(~blocked & ~touched)
            if rows.size:
                ax.plot(
                    cols, rows, color=tokens.faint, alpha=0.55, zorder=2,
                    label="unvisited" if legend else None, **MARKS["unvisited"],
                )

        if closed.size:
            ax.plot(
                closed[:, 1], closed[:, 0], color=tokens.expanded, zorder=3,
                label="expanded" if legend else None, **MARKS["expanded"],
            )
        if open_list.size:
            ax.plot(
                open_list[:, 1], open_list[:, 0], markeredgecolor=tokens.frontier,
                zorder=4, label="frontier" if legend else None, **MARKS["frontier"],
            )
        if solution.size:
            # Drawn last, and the only connected element in the figure.
            ax.plot(
                solution[:, 1], solution[:, 0], color=tokens.path, zorder=5,
                label="path" if legend else None, **MARKS["path"],
            )

        origin = start if start is not None else (
            tuple(solution[0]) if solution.size else None
        )
        target = goal if goal is not None else (
            tuple(solution[-1]) if solution.size else None
        )
        if origin is not None:
            ax.plot(
                origin[1], origin[0], markeredgecolor=tokens.path, zorder=6,
                label="start" if legend else None, **MARKS["start"],
            )
        if target is not None:
            ax.plot(
                target[1], target[0], color=tokens.path,
                markeredgecolor=tokens.bg_raised, markeredgewidth=1.0, zorder=6,
                label="goal" if legend else None, **MARKS["goal"],
            )

        if legend:
            handles, labels = ax.get_legend_handles_labels()
            if handles:
                ax.legend(
                    handles,
                    labels,
                    frameon=False,
                    labelcolor=tokens.body,
                    fontsize=8.5,
                    loc="upper left",
                    bbox_to_anchor=(0.0, -0.02),
                    ncol=min(len(handles), 3),
                    handletextpad=0.5,
                    columnspacing=1.4,
                )
        _title(ax, tokens, title)
    return ax

Animations

planviz.animate

Animations: agents executing a plan, and the search that produced it.

Both build a :class:~matplotlib.animation.FuncAnimation and neither writes a file — use :func:save_animation for that, or :func:to_jshtml to embed one in a notebook.

:func:animate_search follows the brand's one rule for search animations: the frontier moves, the expanded set accumulates, and the path appears once at the end and stays. The accumulated closed list is the cost of the search, so fading it out hides the thing the figure is arguing about.

GIFs are capped at 12 fps and 800 px wide by :func:save_animation, because they are read in a GitHub README on a train.

animate_paths

animate_paths(paths: Any, grid: Any = None, *, shape: tuple[int, int] | None = None, dark: bool = False, substeps: int = SUBSTEPS, trail: int = 8, hold: int = 12, title: str | None = None, figsize: tuple[float, float] | None = None, interval: int = 1000 // GIF_FPS) -> FuncAnimation

Animate agents executing a multi-agent plan over a grid.

Each agent glides between cells with an eased step, drags a short trail, and has a static goal ring drawn once. Colours come from the agent ramp by stable index, exactly as in :func:planviz.draw_paths.

Parameters:

Name Type Description Default
paths Any

{name: [(row, col), ...]} or a sequence of paths. Paths of different lengths are held at their last cell, which is what a MAPF agent parked on its goal actually does.

required
grid Any

optional occupancy grid to draw underneath.

None
shape tuple[int, int] | None

(height, width) when there is no grid.

None
dark bool

use the dark scheme.

False
substeps int

interpolation frames per timestep; higher is smoother.

SUBSTEPS
trail int

how many timesteps of history stay visible behind each agent.

8
hold int

extra frames at the end so the final state is readable before a looping GIF restarts.

12
title str | None

axes title.

None
figsize tuple[float, float] | None

overrides the size derived from the grid's aspect.

None
interval int

milliseconds between frames in an interactive backend.

1000 // GIF_FPS

Returns:

Name Type Description
A FuncAnimation

class:~matplotlib.animation.FuncAnimation. Keep a reference to it

FuncAnimation

or it is garbage-collected before it renders.

Example

import matplotlib; matplotlib.use("Agg") import planviz anim = planviz.animate_paths( ... {"a": [(0, 0), (0, 1), (0, 2)], "b": [(2, 2), (1, 2), (0, 2)]}, ... shape=(3, 3), ... ) sum(1 for _ in anim.new_frame_seq()) > 0 True

Source code in planviz/animate.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def animate_paths(
    paths: Any,
    grid: Any = None,
    *,
    shape: tuple[int, int] | None = None,
    dark: bool = False,
    substeps: int = SUBSTEPS,
    trail: int = 8,
    hold: int = 12,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
    interval: int = 1000 // GIF_FPS,
) -> FuncAnimation:
    """Animate agents executing a multi-agent plan over a grid.

    Each agent glides between cells with an eased step, drags a short trail,
    and has a static goal ring drawn once. Colours come from the agent ramp by
    stable index, exactly as in :func:`planviz.draw_paths`.

    Args:
        paths: ``{name: [(row, col), ...]}`` or a sequence of paths. Paths of
            different lengths are held at their last cell, which is what a
            MAPF agent parked on its goal actually does.
        grid: optional occupancy grid to draw underneath.
        shape: ``(height, width)`` when there is no grid.
        dark: use the dark scheme.
        substeps: interpolation frames per timestep; higher is smoother.
        trail: how many timesteps of history stay visible behind each agent.
        hold: extra frames at the end so the final state is readable before a
            looping GIF restarts.
        title: axes title.
        figsize: overrides the size derived from the grid's aspect.
        interval: milliseconds between frames in an interactive backend.

    Returns:
        A :class:`~matplotlib.animation.FuncAnimation`. Keep a reference to it
        or it is garbage-collected before it renders.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> anim = planviz.animate_paths(
        ...     {"a": [(0, 0), (0, 1), (0, 2)], "b": [(2, 2), (1, 2), (0, 2)]},
        ...     shape=(3, 3),
        ... )
        >>> sum(1 for _ in anim.new_frame_seq()) > 0
        True
    """
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation

    routes = _common.as_paths(paths)
    with style_context(dark) as tokens:
        resolved = _shape_of(grid, routes, shape)
        figure, ax = _map_axes(None, tokens, resolved, figsize)
        if grid is not None:
            _draw_obstacles(ax, grid, tokens)

        names = list(routes)
        crowded = len(names) > CROWD
        colors = (
            {name: tokens.expanded for name in names}
            if crowded
            else _common.series_colors(names, tokens)
        )
        horizon = max(len(path) for path in routes.values()) - 1

        for name in names:
            goal = routes[name][-1]
            ax.plot(
                goal[1], goal[0], markeredgecolor=colors[name], alpha=0.55,
                zorder=2, **MARKS["start"],
            )

        trails, bodies, labels = {}, {}, {}
        for name in names:
            (trails[name],) = ax.plot(
                [], [], color=colors[name], linewidth=2.4, alpha=0.5, zorder=3,
                solid_capstyle="round",
            )
            (bodies[name],) = ax.plot(
                [], [], color=colors[name], marker="o", markersize=9,
                markeredgecolor=tokens.bg_raised, markeredgewidth=1.4,
                linestyle="none", zorder=5,
            )
            if not crowded:
                labels[name] = ax.annotate(
                    name, (0, 0), textcoords="offset points", xytext=(0, 11),
                    ha="center", fontsize=8, color=tokens.muted, zorder=6,
                )

        _title(ax, tokens, title)
        clock = ax.annotate(
            "", xy=(0, 0), xycoords="axes fraction", xytext=(0, -14),
            textcoords="offset points", fontsize=9, color=tokens.muted,
            annotation_clip=False,
        )

        def update(frame: int):
            frame = min(frame, horizon * substeps)
            step = frame // substeps
            for name in names:
                path = routes[name]
                row, col = _at(path, frame, substeps)
                bodies[name].set_data([col], [row])
                if name in labels:
                    labels[name].xy = (col, row)
                history = list(path[max(0, step - trail) : step + 1]) + [(row, col)]
                trails[name].set_data(
                    [cell[1] for cell in history], [cell[0] for cell in history]
                )
            clock.set_text(f"t = {step} / {horizon}    ·    {len(names)} agents")
            return list(bodies.values()) + list(trails.values())

        animation = FuncAnimation(
            figure,
            update,
            frames=horizon * substeps + 1 + hold,
            interval=interval,
            blit=False,
        )
        # Keep notebooks from rendering the still first frame beside the player.
        plt.close(figure)
    return animation
animate_search(expansions: Sequence, *, frontiers: Sequence[Sequence] | None = None, path: Sequence | None = None, grid: Any = None, shape: tuple[int, int] | None = None, start: tuple[int, int] | None = None, goal: tuple[int, int] | None = None, frames: int = 90, hold: int = 12, dark: bool = False, title: str | None = None, figsize: tuple[float, float] | None = None, interval: int = 1000 // GIF_FPS) -> FuncAnimation

Animate a search: an accumulating closed list and a moving frontier.

The expanded set never fades — it is the cost of the search. The path is drawn once, in the last frames, and stays.

expansions is resampled to at most frames frames, so a 20-node search and a 200,000-node search produce clips of the same length.

Parameters:

Name Type Description Default
expansions Sequence

cells in the order they left the open list.

required
frontiers Sequence[Sequence] | None

optional open-list snapshot after each expansion, aligned with expansions. Omit it and no frontier is drawn.

None
path Sequence | None

the solution, revealed in the final frames.

None
grid Any

optional occupancy grid to draw underneath.

None
shape tuple[int, int] | None

(height, width) when there is no grid.

None
start tuple[int, int] | None

start cell — a hollow ring, drawn from the first frame.

None
goal tuple[int, int] | None

goal cell — a filled disc, drawn from the first frame.

None
frames int

maximum number of search frames before the hold.

90
hold int

frames the solved state is held for.

12
dark bool

use the dark scheme.

False
title str | None

axes title.

None
figsize tuple[float, float] | None

overrides the size derived from the grid's aspect.

None
interval int

milliseconds between frames in an interactive backend.

1000 // GIF_FPS

Returns:

Name Type Description
A FuncAnimation

class:~matplotlib.animation.FuncAnimation.

Example

import matplotlib; matplotlib.use("Agg") import planviz anim = planviz.animate_search( ... [(0, 0), (0, 1), (1, 1)], path=[(0, 0), (0, 1), (1, 1)], ... shape=(3, 3), frames=4, ... ) sum(1 for _ in anim.new_frame_seq()) > 0 True

Source code in planviz/animate.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def animate_search(
    expansions: Sequence,
    *,
    frontiers: Sequence[Sequence] | None = None,
    path: Sequence | None = None,
    grid: Any = None,
    shape: tuple[int, int] | None = None,
    start: tuple[int, int] | None = None,
    goal: tuple[int, int] | None = None,
    frames: int = 90,
    hold: int = 12,
    dark: bool = False,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
    interval: int = 1000 // GIF_FPS,
) -> FuncAnimation:
    """Animate a search: an accumulating closed list and a moving frontier.

    The expanded set never fades — it is the cost of the search. The path is
    drawn once, in the last frames, and stays.

    ``expansions`` is resampled to at most ``frames`` frames, so a 20-node
    search and a 200,000-node search produce clips of the same length.

    Args:
        expansions: cells in the order they left the open list.
        frontiers: optional open-list snapshot after each expansion, aligned
            with ``expansions``. Omit it and no frontier is drawn.
        path: the solution, revealed in the final frames.
        grid: optional occupancy grid to draw underneath.
        shape: ``(height, width)`` when there is no grid.
        start: start cell — a hollow ring, drawn from the first frame.
        goal: goal cell — a filled disc, drawn from the first frame.
        frames: maximum number of search frames before the hold.
        hold: frames the solved state is held for.
        dark: use the dark scheme.
        title: axes title.
        figsize: overrides the size derived from the grid's aspect.
        interval: milliseconds between frames in an interactive backend.

    Returns:
        A :class:`~matplotlib.animation.FuncAnimation`.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> anim = planviz.animate_search(
        ...     [(0, 0), (0, 1), (1, 1)], path=[(0, 0), (0, 1), (1, 1)],
        ...     shape=(3, 3), frames=4,
        ... )
        >>> sum(1 for _ in anim.new_frame_seq()) > 0
        True
    """
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation

    order = np.asarray(list(expansions), dtype=float)
    if order.ndim != 2 or order.shape[1] != 2:
        raise ValueError("expansions must be a sequence of (row, col) pairs")
    total = len(order)
    solution = np.asarray(list(path), dtype=float) if path is not None else None

    with style_context(dark) as tokens:
        pool_shape = shape
        if pool_shape is None and grid is None:
            stack = order if solution is None else np.vstack([order, solution])
            pool_shape = (int(stack[:, 0].max()) + 1, int(stack[:, 1].max()) + 1)
        resolved = _shape_of(grid, None, pool_shape)
        figure, ax = _map_axes(None, tokens, resolved, figsize)
        if grid is not None:
            _draw_obstacles(ax, grid, tokens)

        (closed_art,) = ax.plot(
            [], [], color=tokens.expanded, zorder=3, **MARKS["expanded"]
        )
        (open_art,) = ax.plot(
            [], [], markeredgecolor=tokens.frontier, zorder=4, **MARKS["frontier"]
        )
        (path_art,) = ax.plot([], [], color=tokens.path, zorder=5, **MARKS["path"])
        if start is not None:
            ax.plot(
                start[1], start[0], markeredgecolor=tokens.path, zorder=6,
                **MARKS["start"],
            )
        if goal is not None:
            ax.plot(
                goal[1], goal[0], color=tokens.path,
                markeredgecolor=tokens.bg_raised, markeredgewidth=1.0, zorder=6,
                **MARKS["goal"],
            )

        _title(ax, tokens, title)
        counter = ax.annotate(
            "", xy=(0, 0), xycoords="axes fraction", xytext=(0, -14),
            textcoords="offset points", fontsize=9, color=tokens.muted,
            annotation_clip=False,
        )

        search_frames = min(max(1, frames), total)
        cuts = [
            max(1, round((i + 1) / search_frames * total))
            for i in range(search_frames)
        ]

        def update(index: int):
            step = cuts[min(index, search_frames - 1)]
            closed = order[:step]
            closed_art.set_data(closed[:, 1], closed[:, 0])
            if frontiers is not None:
                snapshot = np.asarray(
                    list(frontiers[min(step - 1, len(frontiers) - 1)]), dtype=float
                ).reshape(-1, 2)
                open_art.set_data(snapshot[:, 1], snapshot[:, 0])
            if index >= search_frames - 1 and solution is not None:
                path_art.set_data(solution[:, 1], solution[:, 0])
                open_art.set_data([], [])
                counter.set_text(
                    f"solved  ·  {total} expansions  ·  path length "
                    f"{len(solution)}"
                )
            else:
                counter.set_text(f"searching  ·  {step} / {total} expansions")
            return [closed_art, open_art, path_art, counter]

        animation = FuncAnimation(
            figure,
            update,
            frames=search_frames + hold,
            interval=interval,
            blit=False,
            repeat_delay=1200,
        )
        plt.close(figure)
    return animation

save_animation

save_animation(animation: FuncAnimation, path: str | Path, *, fps: int = GIF_FPS, width_px: int = GIF_WIDTH_PX, dpi: int | None = None, bitrate: int = 3200) -> Path

Write an animation to .gif (pillow) or .mp4 (ffmpeg).

For a GIF the defaults are the brand's README cap — 12 fps, 800 px wide — and dpi is derived from width_px so the cap holds whatever figure size produced the animation. Pass dpi to override.

Parameters:

Name Type Description Default
animation FuncAnimation

a :class:~matplotlib.animation.FuncAnimation.

required
path str | Path

destination; the suffix picks the writer.

required
fps int

frames per second.

GIF_FPS
width_px int

target pixel width, used to derive dpi.

GIF_WIDTH_PX
dpi int | None

explicit dots per inch, overriding width_px.

None
bitrate int

MP4 bitrate.

3200

Returns:

Name Type Description
The Path

class:~pathlib.Path written.

Example

import matplotlib; matplotlib.use("Agg") import planviz, os, tempfile anim = planviz.animate_search([(0, 0), (1, 1)], shape=(2, 2), frames=2) out = planviz.save_animation( ... anim, os.path.join(tempfile.mkdtemp(), "search.gif") ... ) out.suffix '.gif'

Source code in planviz/animate.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def save_animation(
    animation: FuncAnimation,
    path: str | Path,
    *,
    fps: int = GIF_FPS,
    width_px: int = GIF_WIDTH_PX,
    dpi: int | None = None,
    bitrate: int = 3200,
) -> Path:
    """Write an animation to ``.gif`` (pillow) or ``.mp4`` (ffmpeg).

    For a GIF the defaults are the brand's README cap — 12 fps, 800 px wide —
    and ``dpi`` is derived from ``width_px`` so the cap holds whatever figure
    size produced the animation. Pass ``dpi`` to override.

    Args:
        animation: a :class:`~matplotlib.animation.FuncAnimation`.
        path: destination; the suffix picks the writer.
        fps: frames per second.
        width_px: target pixel width, used to derive ``dpi``.
        dpi: explicit dots per inch, overriding ``width_px``.
        bitrate: MP4 bitrate.

    Returns:
        The :class:`~pathlib.Path` written.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz, os, tempfile
        >>> anim = planviz.animate_search([(0, 0), (1, 1)], shape=(2, 2), frames=2)
        >>> out = planviz.save_animation(
        ...     anim, os.path.join(tempfile.mkdtemp(), "search.gif")
        ... )
        >>> out.suffix
        '.gif'
    """
    target = Path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    figure = animation._fig  # the figure the animation was built on
    if dpi is None:
        dpi = max(40, int(round(width_px / figure.get_size_inches()[0])))

    if target.suffix.lower() == ".gif":
        animation.save(str(target), writer="pillow", fps=min(fps, GIF_FPS), dpi=dpi)
        return target

    import matplotlib as mpl

    try:  # a bundled ffmpeg is the difference between "works" and "install ffmpeg"
        import imageio_ffmpeg

        mpl.rcParams["animation.ffmpeg_path"] = imageio_ffmpeg.get_ffmpeg_exe()
    except Exception:  # pragma: no cover - a system ffmpeg is fine too
        pass

    from matplotlib.animation import FFMpegWriter

    writer = FFMpegWriter(
        fps=fps,
        bitrate=bitrate,
        codec="libx264",
        extra_args=["-pix_fmt", "yuv420p", "-preset", "slow"],
    )
    animation.save(str(target), writer=writer, dpi=dpi)
    return target

to_jshtml

to_jshtml(animation: FuncAnimation) -> str

Return an HTML/JS player for the animation — what a notebook shows.

Example

import matplotlib; matplotlib.use("Agg") import planviz anim = planviz.animate_search([(0, 0), (1, 1)], shape=(2, 2), frames=2) planviz.to_jshtml(anim).lstrip().startswith("<") True

Source code in planviz/animate.py
394
395
396
397
398
399
400
401
402
403
404
def to_jshtml(animation: FuncAnimation) -> str:
    """Return an HTML/JS player for the animation — what a notebook shows.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> anim = planviz.animate_search([(0, 0), (1, 1)], shape=(2, 2), frames=2)
        >>> planviz.to_jshtml(anim).lstrip().startswith("<")
        True
    """
    return animation.to_jshtml()

Search progress

planviz.search

Search progress, the radial wavefront, and plan timelines.

These are the pictures a single-agent planner needs — jupyddl's --plot, --tree and --plan-plot in library form:

  • :func:search_progress / :func:search_panels — how f, g, h, the open-list size and the node counters moved as the search ran.
  • :func:radial_wavefront — the search tree in polar coordinates: radius is depth, so a breadth-first flood is a disc and a greedy dive is a spoke.
  • :func:plan_timeline — the returned plan as a Gantt, with waiting drawn as its own mark rather than as an absence.

None of them import a planner. A trace is a mapping of named series; a plan is a sequence of steps; a tree is a flat sequence of (node, parent, depth) records where a parent may be a node that was never itself expanded.

Step dataclass

One segment of a plan timeline.

Parameters:

Name Type Description Default
label str

what to write on (or beside) the bar.

required
start float

when the step begins, in whatever unit the axis is in.

0.0
duration float

how long it lasts. Unit-duration steps give a staircase.

1.0
row str | int | None

the lane this step belongs to — an agent name, an object, or None for "a lane of its own".

None
kind str

"action", "wait" (hatched — a wait is a decision, not a gap) or "idle" (faint — parked, having finished).

'action'
Example

from planviz import Step Step("move(a, b)", 0.0, 2.5, row="robot1").kind 'action'

Source code in planviz/search.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
@dataclass(frozen=True)
class Step:
    """One segment of a plan timeline.

    Args:
        label: what to write on (or beside) the bar.
        start: when the step begins, in whatever unit the axis is in.
        duration: how long it lasts. Unit-duration steps give a staircase.
        row: the lane this step belongs to — an agent name, an object, or
            ``None`` for "a lane of its own".
        kind: ``"action"``, ``"wait"`` (hatched — a wait is a decision, not a
            gap) or ``"idle"`` (faint — parked, having finished).

    Example:
        >>> from planviz import Step
        >>> Step("move(a, b)", 0.0, 2.5, row="robot1").kind
        'action'
    """

    label: str
    start: float = 0.0
    duration: float = 1.0
    row: str | int | None = None
    kind: str = "action"

search_progress

search_progress(series: Mapping[str, Sequence[float]], *, x: Sequence[float] | None = None, ax: Axes | None = None, dark: bool = False, highlight: str | None = None, marks: Sequence[float] | None = None, fill: bool = False, log_y: bool = False, end_labels: bool = True, legend: bool = False, xlabel: str = 'nodes expanded', ylabel: str | None = None, title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Plot one line per named series against search progress.

Use it for f/g/h over expansions, for the open-list size, for the cost of each expanded node, or for one line per planner. Colours come from the agent ramp; highlight promotes one series to path, which is how the brand says a comparison should make its argument — one loud line, the rest supporting.

Parameters:

Name Type Description Default
series Mapping[str, Sequence[float]]

{name: values}. Series may differ in length.

required
x Sequence[float] | None

shared x values; defaults to 1..n.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
highlight str | None

the series being argued for; it takes the path accent.

None
marks Sequence[float] | None

x positions to draw as dashed vertical rules — an IDA* bound restart, a replanning event, a timeout.

None
fill bool

shade under each line. Sensible for a single frontier-size series, noisy for several.

False
log_y bool

log-scale the y axis. It is labelled, because a silent log axis is a way to be misleading by accident.

False
end_labels bool

annotate each line at its end instead of in a legend.

True
legend bool

draw a legend box as well.

False
xlabel str

x axis label.

'nodes expanded'
ylabel str | None

y axis label.

None
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.search_progress( ... {"f": [4, 4, 5, 5, 6], "g": [0, 1, 2, 3, 4], "h": [4, 3, 3, 2, 2]}, ... ylabel="cost", ... ) len(ax.lines) 3

Source code in planviz/search.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def search_progress(
    series: Mapping[str, Sequence[float]],
    *,
    x: Sequence[float] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    highlight: str | None = None,
    marks: Sequence[float] | None = None,
    fill: bool = False,
    log_y: bool = False,
    end_labels: bool = True,
    legend: bool = False,
    xlabel: str = "nodes expanded",
    ylabel: str | None = None,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Plot one line per named series against search progress.

    Use it for ``f``/``g``/``h`` over expansions, for the open-list size, for
    the cost of each expanded node, or for one line per planner. Colours come
    from the agent ramp; ``highlight`` promotes one series to ``path``, which
    is how the brand says a comparison should make its argument — one loud
    line, the rest supporting.

    Args:
        series: ``{name: values}``. Series may differ in length.
        x: shared x values; defaults to ``1..n``.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        highlight: the series being argued for; it takes the ``path`` accent.
        marks: x positions to draw as dashed vertical rules — an IDA\\* bound
            restart, a replanning event, a timeout.
        fill: shade under each line. Sensible for a single frontier-size
            series, noisy for several.
        log_y: log-scale the y axis. It is labelled, because a silent log axis
            is a way to be misleading by accident.
        end_labels: annotate each line at its end instead of in a legend.
        legend: draw a legend box as well.
        xlabel: x axis label.
        ylabel: y axis label.
        title: axes title.
        figsize: figure size when creating the axes.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.search_progress(
        ...     {"f": [4, 4, 5, 5, 6], "g": [0, 1, 2, 3, 4], "h": [4, 3, 3, 2, 2]},
        ...     ylabel="cost",
        ... )
        >>> len(ax.lines)
        3
    """
    with style_context(dark) as tokens:
        _, ax = _common.axes(ax, tokens, figsize or (6.4, 4.0))
        _draw_series(
            ax, series, tokens, x=x, highlight=highlight, fill=fill,
            end_labels=end_labels,
        )
        for mark in marks or ():
            ax.axvline(
                mark, color=tokens.faint, linewidth=1.0, linestyle=(0, (4, 3)),
                zorder=0,
            )
        if log_y:
            ax.set_yscale("log")
            if ylabel and "log" not in ylabel:
                ylabel = f"{ylabel} (log)"
        _common.finish(ax, tokens, title=title, xlabel=xlabel, ylabel=ylabel)
        if legend:
            _common.legend(ax, tokens, loc="best")
        ax.margins(x=0.12 if end_labels else 0.02)
    return ax

search_panels

search_panels(panels: Mapping[str, Mapping[str, Sequence[float]]], *, x: Sequence[float] | None = None, dark: bool = False, ncols: int = 2, highlight: str | None = None, marks: Sequence[float] | None = None, fill: Sequence[str] = (), log_y: Sequence[str] = (), xlabel: str = 'nodes expanded', ylabels: Mapping[str, str] | None = None, suptitle: str | None = None, figsize: tuple[float, float] | None = None) -> Figure

Lay several :func:search_progress panels on one figure.

One idea per panel, one figure per search. The panels share a colour assignment, so a series named the same way in two panels is the same colour in both.

Parameters:

Name Type Description Default
panels Mapping[str, Mapping[str, Sequence[float]]]

{panel title: {series name: values}}, in draw order.

required
x Sequence[float] | None

shared x values for every panel.

None
dark bool

use the dark scheme.

False
ncols int

panels per row.

2
highlight str | None

series promoted to the path accent in every panel.

None
marks Sequence[float] | None

x positions drawn as dashed rules in every panel.

None
fill Sequence[str]

titles of the panels to shade under.

()
log_y Sequence[str]

titles of the panels to log-scale.

()
xlabel str

x axis label, applied to the bottom row.

'nodes expanded'
ylabels Mapping[str, str] | None

{panel title: y label}.

None
suptitle str | None

figure title.

None
figsize tuple[float, float] | None

figure size; derived from the panel count when omitted.

None

Returns:

Name Type Description
The Figure

class:~matplotlib.figure.Figure.

Example

import matplotlib; matplotlib.use("Agg") import planviz fig = planviz.search_panels({ ... "f, g and h": {"f": [4, 5, 6], "g": [0, 1, 2], "h": [4, 4, 4]}, ... "open list": {"|open|": [1, 4, 7]}, ... }) len(fig.axes) 2

Source code in planviz/search.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
def search_panels(
    panels: Mapping[str, Mapping[str, Sequence[float]]],
    *,
    x: Sequence[float] | None = None,
    dark: bool = False,
    ncols: int = 2,
    highlight: str | None = None,
    marks: Sequence[float] | None = None,
    fill: Sequence[str] = (),
    log_y: Sequence[str] = (),
    xlabel: str = "nodes expanded",
    ylabels: Mapping[str, str] | None = None,
    suptitle: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Figure:
    """Lay several :func:`search_progress` panels on one figure.

    One idea per panel, one figure per search. The panels share a colour
    assignment, so a series named the same way in two panels is the same
    colour in both.

    Args:
        panels: ``{panel title: {series name: values}}``, in draw order.
        x: shared x values for every panel.
        dark: use the dark scheme.
        ncols: panels per row.
        highlight: series promoted to the ``path`` accent in every panel.
        marks: x positions drawn as dashed rules in every panel.
        fill: titles of the panels to shade under.
        log_y: titles of the panels to log-scale.
        xlabel: x axis label, applied to the bottom row.
        ylabels: ``{panel title: y label}``.
        suptitle: figure title.
        figsize: figure size; derived from the panel count when omitted.

    Returns:
        The :class:`~matplotlib.figure.Figure`.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> fig = planviz.search_panels({
        ...     "f, g and h": {"f": [4, 5, 6], "g": [0, 1, 2], "h": [4, 4, 4]},
        ...     "open list": {"|open|": [1, 4, 7]},
        ... })
        >>> len(fig.axes)
        2
    """
    import matplotlib.pyplot as plt

    titles = list(panels)
    if not titles:
        raise ValueError("search_panels needs at least one panel")
    ncols = max(1, min(ncols, len(titles)))
    nrows = (len(titles) + ncols - 1) // ncols

    with style_context(dark) as tokens:
        figure, axes = plt.subplots(
            nrows,
            ncols,
            figsize=figsize or (5.6 * ncols, 3.4 * nrows),
            squeeze=False,
        )
        figure.set_facecolor(tokens.bg)
        for index, title in enumerate(titles):
            ax = axes[index // ncols][index % ncols]
            ax.set_facecolor(tokens.bg_raised)
            _draw_series(
                ax,
                panels[title],
                tokens,
                x=x,
                highlight=highlight,
                fill=title in fill,
                end_labels=len(panels[title]) > 1,
            )
            for mark in marks or ():
                ax.axvline(
                    mark, color=tokens.faint, linewidth=1.0,
                    linestyle=(0, (4, 3)), zorder=0,
                )
            ylabel = (ylabels or {}).get(title)
            if title in log_y:
                ax.set_yscale("log")
                ylabel = f"{ylabel} (log)" if ylabel else "log scale"
            bottom = index // ncols == nrows - 1
            _common.finish(
                ax,
                tokens,
                title=title,
                xlabel=xlabel if bottom else "",
                ylabel=ylabel,
            )
            if len(panels[title]) == 1:
                _common.legend(ax, tokens, loc="best")
            ax.margins(x=0.1)
        for index in range(len(titles), nrows * ncols):
            axes[index // ncols][index % ncols].axis("off")
        if suptitle:
            figure.suptitle(
                suptitle, color=tokens.heading, fontsize=13, fontweight="semibold"
            )
        figure.tight_layout(rect=(0, 0, 1, 0.96 if suptitle else 1))
    return figure

radial_wavefront

radial_wavefront(nodes: Iterable, *, frontier: Iterable[int] | None = None, goal: int | None = None, max_nodes: int = 4000, edges: bool = True, ax: Axes | None = None, dark: bool = False, value_label: str = 'heuristic', title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Draw the search tree in polar coordinates — depth is radius.

Nodes at the same depth are spread evenly around their ring, so the shape of the figure is the shape of the search: a uniform-cost flood fills a disc, a greedy best-first dive is a single spoke, and an A* with a good heuristic is a wedge aimed at the goal.

Colour along the expandedpath ramp encodes value (the heuristic by default), warm meaning expensive. Nodes still on the open list take the frontier's hollow ring, so the legend still reads.

Parameters:

Name Type Description Default
nodes Iterable

flat records of expanded nodes, in expansion order. Each is a (node, parent, depth) or (node, parent, depth, value) tuple, a mapping with those keys, or an object with those attributes (h is accepted for value). parent is -1 at the root, and may name a node that was never expanded.

required
frontier Iterable[int] | None

ids of nodes still on the open list.

None
goal int | None

id of the goal node, marked with a filled disc.

None
max_nodes int

cap; beyond a few thousand rings the ink stops resolving.

4000
edges bool

draw parent→child edges. Turn them off for a dense search.

True
ax Axes | None

a polar axes to draw into; one is created when omitted.

None
dark bool

use the dark scheme.

False
value_label str

colourbar label.

'heuristic'
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Type Description
Axes

The polar :class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.radial_wavefront( ... [(0, -1, 0, 3.0), (1, 0, 1, 2.0), (2, 0, 1, 2.5), (3, 1, 2, 1.0)], ... goal=3, ... ) ax.name 'polar'

Source code in planviz/search.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def radial_wavefront(
    nodes: Iterable,
    *,
    frontier: Iterable[int] | None = None,
    goal: int | None = None,
    max_nodes: int = 4000,
    edges: bool = True,
    ax: Axes | None = None,
    dark: bool = False,
    value_label: str = "heuristic",
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Draw the search tree in polar coordinates — depth is radius.

    Nodes at the same depth are spread evenly around their ring, so the shape
    of the figure *is* the shape of the search: a uniform-cost flood fills a
    disc, a greedy best-first dive is a single spoke, and an A\\* with a good
    heuristic is a wedge aimed at the goal.

    Colour along the ``expanded`` → ``path`` ramp encodes ``value`` (the
    heuristic by default), warm meaning expensive. Nodes still on the open
    list take the frontier's hollow ring, so the legend still reads.

    Args:
        nodes: flat records of expanded nodes, in expansion order. Each is a
            ``(node, parent, depth)`` or ``(node, parent, depth, value)``
            tuple, a mapping with those keys, or an object with those
            attributes (``h`` is accepted for ``value``). ``parent`` is ``-1``
            at the root, and may name a node that was never expanded.
        frontier: ids of nodes still on the open list.
        goal: id of the goal node, marked with a filled disc.
        max_nodes: cap; beyond a few thousand rings the ink stops resolving.
        edges: draw parent→child edges. Turn them off for a dense search.
        ax: a **polar** axes to draw into; one is created when omitted.
        dark: use the dark scheme.
        value_label: colourbar label.
        title: axes title.
        figsize: figure size when creating the axes.

    Returns:
        The polar :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.radial_wavefront(
        ...     [(0, -1, 0, 3.0), (1, 0, 1, 2.0), (2, 0, 1, 2.5), (3, 1, 2, 1.0)],
        ...     goal=3,
        ... )
        >>> ax.name
        'polar'
    """
    records = _tree_records(nodes)[:max_nodes]
    if not records:
        raise ValueError("radial_wavefront needs at least one expanded node")

    with style_context(dark) as tokens:
        figure, ax = _common.axes(ax, tokens, figsize or (6.6, 6.2), polar=True)

        by_depth: dict[int, list[tuple[int, int, int, float]]] = {}
        for record in records:
            by_depth.setdefault(record[2], []).append(record)
        angle: dict[int, float] = {}
        radius: dict[int, int] = {}
        # Ring by ring, outwards, ordering each ring by its parents' angles.
        # Spreading a ring in expansion order instead draws every edge as a
        # chord across the disc, which hides the branching the figure is about.
        for depth in sorted(by_depth):
            group = sorted(by_depth[depth], key=lambda r: angle.get(r[1], 0.0))
            for slot, (node, _parent, _depth, _value) in enumerate(group):
                angle[node] = 2 * math.pi * (slot + 0.5) / len(group)
                radius[node] = depth

        if edges:
            for node, parent, _depth, _value in records:
                if parent < 0 or parent not in angle:
                    continue
                ax.plot(
                    [angle[parent], angle[node]],
                    [radius[parent], radius[node]],
                    color=tokens.line,
                    linewidth=0.6,
                    alpha=0.8,
                    zorder=2,
                )

        values = np.array([record[3] for record in records], dtype=float)
        finite = values[np.isfinite(values)]
        cmap = tokens.sequential()
        if finite.size and finite.max() > finite.min():
            norm = (values - finite.min()) / (finite.max() - finite.min())
        else:
            norm = np.full(values.shape, 0.5)
        norm = np.nan_to_num(norm, nan=0.5)
        scatter = ax.scatter(
            [angle[record[0]] for record in records],
            [radius[record[0]] for record in records],
            s=22,
            c=[cmap(v) for v in norm],
            edgecolors=tokens.bg_raised,
            linewidths=0.5,
            zorder=4,
        )
        scatter.set_label("expanded")

        if frontier:
            ids = [node for node in frontier if node in angle]
            if ids:
                ax.plot(
                    [angle[node] for node in ids],
                    [radius[node] for node in ids],
                    markeredgecolor=tokens.frontier,
                    marker="o",
                    markersize=6.0,
                    markerfacecolor="none",
                    markeredgewidth=1.5,
                    linestyle="none",
                    zorder=5,
                    label="frontier",
                )
        if goal is not None and goal in angle:
            ax.plot(
                angle[goal], radius[goal], marker="*", markersize=15,
                color=tokens.path, markeredgecolor=tokens.bg_raised,
                markeredgewidth=0.8, linestyle="none", zorder=6, label="goal",
            )

        ax.set_facecolor(tokens.bg_raised)
        ax.set_xticklabels([])
        ax.set_yticklabels([])
        ax.grid(True, color=tokens.line, linewidth=0.6, alpha=0.9)
        ax.spines["polar"].set_color(tokens.line)
        ax.spines["polar"].set_linewidth(1.0)
        ax.set_ylim(0, max(radius.values()) + 0.6)
        if title:
            ax.set_title(title, color=tokens.heading, fontweight="semibold", pad=16)
        _common.caption(
            ax,
            tokens,
            f"radius = depth · colour = {value_label} "
            f"({len(records)} nodes, {len(by_depth)} depths)",
            y=-0.06,
        )
        if frontier or goal is not None:
            _common.legend(ax, tokens, loc="upper right", bbox_to_anchor=(1.14, 1.10))
    return ax

plan_timeline

plan_timeline(plan: Iterable, *, ax: Axes | None = None, dark: bool = False, highlight: str | int | None = None, max_steps: int = 60, annotate: bool = True, xlabel: str = 'time', title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Draw a plan as a timeline — one bar per step, one lane per actor.

Two shapes fall out of the same function. Give every step its own lane (the default for a plain list of action names) and you get a sequential plan read top to bottom. Give steps a row and you get a Gantt: one lane per agent, per resource, or per object.

Waiting is drawn hatched rather than left blank, because waiting is where coordination cost shows up and an empty gap reads as "nothing happened".

Parameters:

Name Type Description Default
plan Iterable

:class:Step objects, (label, start, duration[, row[, kind]]) tuples, mappings with those keys, or bare action names — which are taken as unit-duration steps in sequence.

required
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
highlight str | int | None

the lane this figure is about; it takes the path accent and the others drop back.

None
max_steps int

truncate longer plans, with a note saying so.

60
annotate bool

write each step's label on its bar when it has its own lane.

True
xlabel str

x axis label.

'time'
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size; derived from the lane count when omitted.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.plan_timeline(["pick(a)", "move(a, b)", "drop(a)"]) len(ax.patches) 3 gantt = planviz.plan_timeline([ ... planviz.Step("move", 0, 2, row="r1"), ... planviz.Step("wait", 2, 1, row="r1", kind="wait"), ... planviz.Step("move", 0, 3, row="r2"), ... ]) len(gantt.get_yticks()) 2

Source code in planviz/search.py
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
def plan_timeline(
    plan: Iterable,
    *,
    ax: Axes | None = None,
    dark: bool = False,
    highlight: str | int | None = None,
    max_steps: int = 60,
    annotate: bool = True,
    xlabel: str = "time",
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Draw a plan as a timeline — one bar per step, one lane per actor.

    Two shapes fall out of the same function. Give every step its own lane
    (the default for a plain list of action names) and you get a sequential
    plan read top to bottom. Give steps a ``row`` and you get a Gantt: one
    lane per agent, per resource, or per object.

    Waiting is drawn hatched rather than left blank, because waiting is where
    coordination cost shows up and an empty gap reads as "nothing happened".

    Args:
        plan: :class:`Step` objects, ``(label, start, duration[, row[, kind]])``
            tuples, mappings with those keys, or bare action names — which are
            taken as unit-duration steps in sequence.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        highlight: the lane this figure is about; it takes the ``path`` accent
            and the others drop back.
        max_steps: truncate longer plans, with a note saying so.
        annotate: write each step's label on its bar when it has its own lane.
        xlabel: x axis label.
        title: axes title.
        figsize: figure size; derived from the lane count when omitted.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.plan_timeline(["pick(a)", "move(a, b)", "drop(a)"])
        >>> len(ax.patches)
        3
        >>> gantt = planviz.plan_timeline([
        ...     planviz.Step("move", 0, 2, row="r1"),
        ...     planviz.Step("wait", 2, 1, row="r1", kind="wait"),
        ...     planviz.Step("move", 0, 3, row="r2"),
        ... ])
        >>> len(gantt.get_yticks())
        2
    """
    from matplotlib.patches import Rectangle

    steps = _steps(plan)
    truncated = len(steps) > max_steps
    shown = steps[:max_steps]
    per_step_lanes = all(step.row is None for step in shown)

    lanes: list[Any] = []
    lane_of: dict[int, int] = {}
    for index, step in enumerate(shown):
        key = index if step.row is None else step.row
        if key not in lanes:
            lanes.append(key)
        lane_of[index] = lanes.index(key)

    with style_context(dark) as tokens:
        height = max(2.6, 0.34 * len(lanes) + 1.4)
        _, ax = _common.axes(ax, tokens, figsize or (8.4, height))

        if per_step_lanes:
            # A progression: the sequential ramp reads as "later is warmer".
            cmap = tokens.sequential()
            colors = [
                cmap(0.25 + 0.6 * i / max(1, len(shown) - 1))
                for i in range(len(shown))
            ]
            lane_color = {i: colors[i] for i in range(len(shown))}
        else:
            ramp = _common.series_colors(
                [str(lane) for lane in lanes],
                tokens,
                str(highlight) if highlight is not None else None,
            )
            if highlight is not None:
                ramp = {
                    name: (tokens.path if name == str(highlight) else tokens.faint)
                    for name in ramp
                }
            lane_color = {i: ramp[str(lane)] for i, lane in enumerate(lanes)}

        for index, step in enumerate(shown):
            lane = lane_of[index]
            color = lane_color[lane if not per_step_lanes else index]
            waiting = step.kind == "wait"
            idle = step.kind == "idle"
            ax.add_patch(
                Rectangle(
                    (step.start, lane - 0.3),
                    max(step.duration, 1e-9),
                    0.6,
                    facecolor=tokens.bg_raised if waiting else color,
                    edgecolor=color,
                    linewidth=1.1 if waiting else 0.0,
                    hatch="///" if waiting else None,
                    alpha=0.28 if idle else 1.0,
                    zorder=3,
                )
            )
            if annotate and per_step_lanes:
                ax.annotate(
                    step.label,
                    (step.start + step.duration, lane),
                    textcoords="offset points",
                    xytext=(6, 0),
                    va="center",
                    fontsize=8.5,
                    color=tokens.body,
                    family="monospace",
                    annotation_clip=False,
                )

        span = max(step.start + step.duration for step in shown)
        ax.set_xlim(0, span * (1.45 if (annotate and per_step_lanes) else 1.04))
        ax.set_ylim(len(lanes) - 0.4, -0.7)  # first lane on top
        ax.set_yticks(range(len(lanes)))
        if per_step_lanes:
            ax.set_yticklabels([str(i + 1) for i in range(len(lanes))], fontsize=8.5)
            ylabel: str | None = "step"
        else:
            ax.set_yticklabels([str(lane) for lane in lanes], fontsize=9)
            ylabel = None
        _common.finish(
            ax, tokens, title=title, xlabel=xlabel, ylabel=ylabel, grid_axis="x"
        )
        notes = []
        if any(step.kind == "wait" for step in shown):
            notes.append("hatched = waiting")
        if truncated:
            notes.append(f"first {max_steps} of {len(steps)} steps")
        if notes:
            _common.caption(ax, tokens, "  ·  ".join(notes), y=1.02)
    return ax

timeline_from_paths

timeline_from_paths(paths: Any, *, wait_label: str = 'wait') -> list[Step]

Turn multi-agent grid paths into :class:Step objects for a timeline.

Contiguous runs of movement become "action" steps, runs where an agent stayed in place become "wait" steps, and the tail an agent spends parked on its goal after arriving becomes an "idle" step. That last distinction matters: a short path in a long plan is not a gap in the figure, it is an agent that finished early.

Parameters:

Name Type Description Default
paths Any

{name: [(row, col), ...]} or a sequence of paths.

required
wait_label str

label written on wait segments.

'wait'

Returns:

Type Description
list[Step]

A list of :class:Step, ready for :func:plan_timeline.

Example

import planviz steps = planviz.timeline_from_paths( ... {"a": [(0, 0), (0, 1), (0, 1), (0, 2)], "b": [(1, 0), (1, 1)]} ... ) for step in steps: ... if step.row == "a": ... print(step.kind, step.start, step.duration) action 0.0 1.0 wait 1.0 1.0 action 2.0 1.0

Source code in planviz/search.py
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
def timeline_from_paths(paths: Any, *, wait_label: str = "wait") -> list[Step]:
    """Turn multi-agent grid paths into :class:`Step` objects for a timeline.

    Contiguous runs of movement become ``"action"`` steps, runs where an agent
    stayed in place become ``"wait"`` steps, and the tail an agent spends
    parked on its goal after arriving becomes an ``"idle"`` step. That last
    distinction matters: a short path in a long plan is not a gap in the
    figure, it is an agent that finished early.

    Args:
        paths: ``{name: [(row, col), ...]}`` or a sequence of paths.
        wait_label: label written on wait segments.

    Returns:
        A list of :class:`Step`, ready for :func:`plan_timeline`.

    Example:
        >>> import planviz
        >>> steps = planviz.timeline_from_paths(
        ...     {"a": [(0, 0), (0, 1), (0, 1), (0, 2)], "b": [(1, 0), (1, 1)]}
        ... )
        >>> for step in steps:
        ...     if step.row == "a":
        ...         print(step.kind, step.start, step.duration)
        action 0.0 1.0
        wait 1.0 1.0
        action 2.0 1.0
    """
    routes = _common.as_paths(paths)
    horizon = max(len(path) for path in routes.values()) - 1
    steps: list[Step] = []
    for name, path in routes.items():
        start = 0
        while start < len(path) - 1:
            waiting = path[start] == path[start + 1]
            end = start + 1
            while (
                end < len(path) - 1
                and (path[end] == path[end + 1]) == waiting
            ):
                end += 1
            steps.append(
                Step(
                    wait_label if waiting else "move",
                    float(start),
                    float(end - start),
                    name,
                    "wait" if waiting else "action",
                )
            )
            start = end
        arrival = len(path) - 1
        if arrival < horizon:
            steps.append(
                Step("parked", float(arrival), float(horizon - arrival), name, "idle")
            )
    return steps

Benchmarks

planviz.benchmarks

Benchmark charts: scaling, coverage, where the time goes, and crossovers.

A benchmark plot is an argument, so the ink goes into the comparison. Three rules from brand/figures.md are built in rather than left to the caller:

  • One loud line. highlight gives the series being argued for the path accent; everything else takes the agent ramp as a supporting neutral. A chart where every series is loud makes no argument.
  • Spread, not just the middle. Curves draw the median over seeds with a min–max band, because a median alone hides a bimodal solver.
  • Log axes say so. Planner runtimes span four orders of magnitude, and a silent log axis is a way to be misleading by accident, so (log) is appended to the label.

Every function takes plain mappings — no benchmark harness type is imported — and returns the axes or figure without saving it.

scaling_curve

scaling_curve(series: Mapping[str, Any], *, x: Sequence[float] | None = None, ax: Axes | None = None, dark: bool = False, highlight: str | None = None, band: bool = True, timeouts: Mapping[str, Sequence[float]] | None = None, cap: float | None = None, log_x: bool = False, log_y: bool = True, x_base: int = 2, marker: str = 'o', legend: bool = True, xlabel: str = 'agents', ylabel: str = 'wall time (s)', title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Plot cost against problem size — one line per backend or planner.

Each line is the median over seeds with a min–max band, which is the honest summary of a stochastic benchmark: a median alone hides a solver that is fast four times in five and pathological on the fifth.

Runs that hit the time limit are drawn as their own mark — a hollow triangle at the cap — rather than extrapolated or silently dropped. A missing point and a timeout are different results and the figure should say which one it is.

Parameters:

Name Type Description Default
series Mapping[str, Any]

{name: {x: [samples over seeds]}}. A scalar per x, or a bare sequence paired with x, also works — with no band.

required
x Sequence[float] | None

shared x values, when series holds bare sequences.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
highlight str | None

the series being argued for; it takes the path accent.

None
band bool

draw the min–max band.

True
timeouts Mapping[str, Sequence[float]] | None

{name: [x values that timed out]}.

None
cap float | None

the time limit, where timeout marks are drawn. Defaults to the largest median in the figure.

None
log_x bool

log-scale x (base x_base) — usual for a doubling sweep.

False
log_y bool

log-scale y.

True
x_base int

base of the x log scale.

2
marker str

marker on each measured point.

'o'
legend bool

draw a legend.

True
xlabel str

x axis label.

'agents'
ylabel str

y axis label.

'wall time (s)'
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.scaling_curve( ... { ... "cpu": {8: [0.4, 0.5], 16: [1.6, 1.9]}, ... "cuda": {8: [0.2, 0.2], 16: [0.3, 0.4]}, ... }, ... highlight="cuda", ... timeouts={"cpu": [32]}, ... cap=10.0, ... ) ax.get_ylabel() 'wall time (s) (log)'

Source code in planviz/benchmarks.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def scaling_curve(
    series: Mapping[str, Any],
    *,
    x: Sequence[float] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    highlight: str | None = None,
    band: bool = True,
    timeouts: Mapping[str, Sequence[float]] | None = None,
    cap: float | None = None,
    log_x: bool = False,
    log_y: bool = True,
    x_base: int = 2,
    marker: str = "o",
    legend: bool = True,
    xlabel: str = "agents",
    ylabel: str = "wall time (s)",
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Plot cost against problem size — one line per backend or planner.

    Each line is the **median over seeds** with a min–max band, which is the
    honest summary of a stochastic benchmark: a median alone hides a solver
    that is fast four times in five and pathological on the fifth.

    Runs that hit the time limit are drawn as their own mark — a hollow
    triangle at the cap — rather than extrapolated or silently dropped. A
    missing point and a timeout are different results and the figure should
    say which one it is.

    Args:
        series: ``{name: {x: [samples over seeds]}}``. A scalar per ``x``, or
            a bare sequence paired with ``x``, also works — with no band.
        x: shared x values, when ``series`` holds bare sequences.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        highlight: the series being argued for; it takes the ``path`` accent.
        band: draw the min–max band.
        timeouts: ``{name: [x values that timed out]}``.
        cap: the time limit, where timeout marks are drawn. Defaults to the
            largest median in the figure.
        log_x: log-scale x (base ``x_base``) — usual for a doubling sweep.
        log_y: log-scale y.
        x_base: base of the x log scale.
        marker: marker on each measured point.
        legend: draw a legend.
        xlabel: x axis label.
        ylabel: y axis label.
        title: axes title.
        figsize: figure size when creating the axes.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.scaling_curve(
        ...     {
        ...         "cpu": {8: [0.4, 0.5], 16: [1.6, 1.9]},
        ...         "cuda": {8: [0.2, 0.2], 16: [0.3, 0.4]},
        ...     },
        ...     highlight="cuda",
        ...     timeouts={"cpu": [32]},
        ...     cap=10.0,
        ... )
        >>> ax.get_ylabel()
        'wall time (s) (log)'
    """
    names = list(series)
    with style_context(dark) as tokens:
        _, ax = _common.axes(ax, tokens, figsize or (6.8, 4.2))
        colors = _common.series_colors(names, tokens, highlight)
        peak = 0.0
        for name in names:
            xs, median, low, high = _samples(series[name], x)
            if not xs:
                continue
            peak = max(peak, max(median))
            ax.plot(
                xs, median, marker=marker, color=colors[name], linewidth=2.0,
                markersize=6, markeredgecolor=tokens.bg_raised,
                markeredgewidth=1.0, label=name, zorder=4,
            )
            spread = any(h > lo for lo, h in zip(low, high, strict=True))
            if band and len(xs) > 1 and spread:
                ax.fill_between(
                    xs, low, high, color=colors[name], alpha=0.15, lw=0, zorder=2
                )
        ceiling = cap if cap is not None else (peak or None)
        for name, xs in (timeouts or {}).items():
            points = [float(value) for value in xs]
            if not points or ceiling is None:
                continue
            ax.plot(
                points, [ceiling] * len(points), marker="^", markerfacecolor="none",
                markeredgecolor=colors.get(name, tokens.faint), linestyle="none",
                markersize=8, markeredgewidth=1.4, zorder=5,
                label=f"{name} (timeout)",
            )
        _log_axes(
            ax, tokens, log_x=log_x, log_y=log_y, x_base=x_base,
            xlabel=xlabel, ylabel=ylabel, title=title,
        )
        if legend:
            _common.legend(ax, tokens, loc="best")
    return ax

success_heatmap

success_heatmap(matrix: Any, *, x_labels: Sequence[str] | None = None, y_labels: Sequence[str] | None = None, ax: Axes | None = None, dark: bool = False, annotate: bool = True, vmin: float = 0.0, vmax: float = 1.0, missing: str = '—', percent: bool = True, xlabel: str = 'agents', ylabel: str = 'obstacle density', title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Draw coverage over a two-axis sweep — how often the solver succeeded.

The ramp runs path (warm, nothing solved) to expanded (cool, all solved), which agrees with the rest of the system: warm is expensive.

Cells with nothing to report are drawn as an em dash, not as zero. "No seed reported here" and "every seed failed here" are different claims, and a heatmap that conflates them is wrong in the direction that flatters the solver.

Parameters:

Name Type Description Default
matrix Any

2-D array-like of rates in [0, 1]; None or NaN marks a cell that was not measured.

required
x_labels Sequence[str] | None

column tick labels.

None
y_labels Sequence[str] | None

row tick labels.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
annotate bool

write the rate in each cell.

True
vmin float

value mapped to the warm end.

0.0
vmax float

value mapped to the cool end.

1.0
missing str

text drawn in unmeasured cells.

'—'
percent bool

format annotations as percentages.

True
xlabel str

x axis label.

'agents'
ylabel str

y axis label.

'obstacle density'
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.success_heatmap( ... [[1.0, 1.0, 0.66], [1.0, 0.33, None]], ... x_labels=["8", "16", "32"], y_labels=["5%", "15%"], ... ) ax.get_xlabel() 'agents'

Source code in planviz/benchmarks.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def success_heatmap(
    matrix: Any,
    *,
    x_labels: Sequence[str] | None = None,
    y_labels: Sequence[str] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    annotate: bool = True,
    vmin: float = 0.0,
    vmax: float = 1.0,
    missing: str = "—",
    percent: bool = True,
    xlabel: str = "agents",
    ylabel: str = "obstacle density",
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Draw coverage over a two-axis sweep — how often the solver succeeded.

    The ramp runs ``path`` (warm, nothing solved) to ``expanded`` (cool, all
    solved), which agrees with the rest of the system: warm is expensive.

    Cells with nothing to report are drawn as an em dash, not as zero. "No
    seed reported here" and "every seed failed here" are different claims, and
    a heatmap that conflates them is wrong in the direction that flatters the
    solver.

    Args:
        matrix: 2-D array-like of rates in ``[0, 1]``; ``None`` or ``NaN``
            marks a cell that was not measured.
        x_labels: column tick labels.
        y_labels: row tick labels.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        annotate: write the rate in each cell.
        vmin: value mapped to the warm end.
        vmax: value mapped to the cool end.
        missing: text drawn in unmeasured cells.
        percent: format annotations as percentages.
        xlabel: x axis label.
        ylabel: y axis label.
        title: axes title.
        figsize: figure size when creating the axes.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.success_heatmap(
        ...     [[1.0, 1.0, 0.66], [1.0, 0.33, None]],
        ...     x_labels=["8", "16", "32"], y_labels=["5%", "15%"],
        ... )
        >>> ax.get_xlabel()
        'agents'
    """
    from matplotlib.colors import LinearSegmentedColormap

    values = np.array(
        [[np.nan if cell is None else float(cell) for cell in row] for row in matrix],
        dtype=float,
    )
    rows, cols = values.shape

    with style_context(dark) as tokens:
        _, ax = _common.axes(
            ax,
            tokens,
            figsize or (max(4.0, 0.7 * cols + 1.6), max(2.6, 0.6 * rows + 1.6)),
        )
        cmap = LinearSegmentedColormap.from_list(
            "coverage", [tokens.path, tokens.expanded], N=256
        )
        # An unmeasured cell takes ``line`` — the same value as an obstacle,
        # because it is structure rather than data. Left transparent it shows
        # the panel through, which on a warm-to-cool ramp reads as a value.
        cmap = cmap.copy()
        cmap.set_bad(tokens.line)
        ax.imshow(
            np.ma.masked_invalid(values),
            cmap=cmap,
            vmin=vmin,
            vmax=vmax,
            aspect="auto",
            interpolation="nearest",
        )
        if annotate:
            for row in range(rows):
                for col in range(cols):
                    value = values[row, col]
                    if np.isnan(value):
                        ax.text(
                            col, row, missing, ha="center", va="center",
                            fontsize=8.5, color=tokens.faint,
                        )
                    else:
                        text = f"{value:.0%}" if percent else f"{value:g}"
                        ax.text(
                            col, row, text, ha="center", va="center", fontsize=8.5,
                            color=tokens.bg_raised if value < 0.7 else tokens.heading,
                        )
        ax.set_xticks(range(cols))
        ax.set_xticklabels(
            list(x_labels) if x_labels else [str(i) for i in range(cols)]
        )
        ax.set_yticks(range(rows))
        ax.set_yticklabels(
            list(y_labels) if y_labels else [str(i) for i in range(rows)]
        )
        _common.finish(
            ax, tokens, title=title, xlabel=xlabel, ylabel=ylabel, grid_axis=None
        )
    return ax

phase_breakdown

phase_breakdown(phases: Mapping[Any, Mapping[str, float]], *, order: Sequence[str] | None = None, accent: str | None = None, neutral: Iterable[str] = (), ax: Axes | None = None, dark: bool = False, normalize: bool = True, totals: bool = True, total_format: str = '{:.2f}s', legend: bool = True, xlabel: str = 'batch size', ylabel: str | None = None, title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Stack where the wall time went, one bar per configuration.

Bands are separated by hatch as well as hue. Four cool blues in a stack read identically in greyscale and to a colour-blind reader — this is the failure cuda-planning hit with its host-to-device and device-to-host bands — so the second channel is not optional here.

Parameters:

Name Type Description Default
phases Mapping[Any, Mapping[str, float]]

{column label: {phase: seconds}}, in column order.

required
order Sequence[str] | None

phases bottom to top; defaults to first-seen order.

None
accent str | None

the phase the figure is about — the useful work. It takes the path accent, so overhead is visibly everything else.

None
neutral Iterable[str]

phases drawn in faint — idle host time, unaccounted time.

()
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
normalize bool

stack fractions of each column's total rather than seconds.

True
totals bool

write each column's absolute total above its bar. Keep this on with normalize, or the figure loses its units.

True
total_format str

format string for those totals.

'{:.2f}s'
legend bool

draw a legend below the axes.

True
xlabel str

x axis label.

'batch size'
ylabel str | None

y axis label; derived from normalize when omitted.

None
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.phase_breakdown( ... { ... "64": {"kernel": 0.4, "h2d": 0.1, "d2h": 0.1, "host": 0.2}, ... "128": {"kernel": 1.1, "h2d": 0.2, "d2h": 0.2, "host": 0.3}, ... }, ... accent="kernel", neutral=["host"], ... ) ax.get_ylabel() 'fraction of wall time'

Source code in planviz/benchmarks.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def phase_breakdown(
    phases: Mapping[Any, Mapping[str, float]],
    *,
    order: Sequence[str] | None = None,
    accent: str | None = None,
    neutral: Iterable[str] = (),
    ax: Axes | None = None,
    dark: bool = False,
    normalize: bool = True,
    totals: bool = True,
    total_format: str = "{:.2f}s",
    legend: bool = True,
    xlabel: str = "batch size",
    ylabel: str | None = None,
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Stack where the wall time went, one bar per configuration.

    Bands are separated by **hatch as well as hue**. Four cool blues in a
    stack read identically in greyscale and to a colour-blind reader — this is
    the failure cuda-planning hit with its host-to-device and device-to-host
    bands — so the second channel is not optional here.

    Args:
        phases: ``{column label: {phase: seconds}}``, in column order.
        order: phases bottom to top; defaults to first-seen order.
        accent: the phase the figure is about — the useful work. It takes the
            ``path`` accent, so overhead is visibly everything else.
        neutral: phases drawn in ``faint`` — idle host time, unaccounted time.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        normalize: stack fractions of each column's total rather than seconds.
        totals: write each column's absolute total above its bar. Keep this
            on with ``normalize``, or the figure loses its units.
        total_format: format string for those totals.
        legend: draw a legend below the axes.
        xlabel: x axis label.
        ylabel: y axis label; derived from ``normalize`` when omitted.
        title: axes title.
        figsize: figure size when creating the axes.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.phase_breakdown(
        ...     {
        ...         "64": {"kernel": 0.4, "h2d": 0.1, "d2h": 0.1, "host": 0.2},
        ...         "128": {"kernel": 1.1, "h2d": 0.2, "d2h": 0.2, "host": 0.3},
        ...     },
        ...     accent="kernel", neutral=["host"],
        ... )
        >>> ax.get_ylabel()
        'fraction of wall time'
    """
    columns = list(phases)
    if not columns:
        raise ValueError("phase_breakdown needs at least one column")
    seen: list[str] = []
    for column in columns:
        for phase in phases[column]:
            if phase not in seen:
                seen.append(phase)
    bands = list(order) if order else seen
    neutral = set(neutral)

    with style_context(dark) as tokens:
        _, ax = _common.axes(
            ax, tokens, figsize or (max(5.2, 1.1 * len(columns) + 2.4), 4.2)
        )
        ramp = [
            name for name in bands if name != accent and name not in neutral
        ]
        colors = {}
        for name in bands:
            if name == accent:
                colors[name] = tokens.path
            elif name in neutral:
                colors[name] = tokens.faint
            else:
                colors[name] = tokens.agent(ramp.index(name))

        positions = np.arange(len(columns))
        bottom = np.zeros(len(columns))
        for index, phase in enumerate(bands):
            heights = np.array(
                [
                    (
                        phases[column].get(phase, 0.0)
                        / max(sum(phases[column].values()), 1e-12)
                        if normalize
                        else phases[column].get(phase, 0.0)
                    )
                    for column in columns
                ]
            )
            ax.bar(
                positions,
                heights,
                bottom=bottom,
                width=0.62,
                label=phase,
                color=colors[phase],
                hatch=PHASE_HATCHES[index % len(PHASE_HATCHES)],
                edgecolor=tokens.bg_raised,
                linewidth=0.6,
                zorder=3,
            )
            bottom += heights
        if totals:
            for index, column in enumerate(columns):
                ax.annotate(
                    total_format.format(sum(phases[column].values())),
                    (positions[index], bottom[index]),
                    textcoords="offset points",
                    xytext=(0, 5),
                    ha="center",
                    fontsize=8.5,
                    color=tokens.muted,
                )
        ax.set_xticks(positions)
        ax.set_xticklabels([str(column) for column in columns])
        ax.set_ylim(0, float(bottom.max()) * 1.14)
        if ylabel is None:
            ylabel = "fraction of wall time" if normalize else "seconds"
        _common.finish(ax, tokens, title=title, xlabel=xlabel, ylabel=ylabel)
        if legend:
            _common.legend(
                ax,
                tokens,
                loc="upper center",
                bbox_to_anchor=(0.5, -0.16),
                ncol=min(len(bands), 4),
            )
    return ax

throughput_curve

throughput_curve(series: Mapping[str, Any], *, x: Sequence[float] | None = None, ax: Axes | None = None, dark: bool = False, highlight: str | None = None, band: bool = True, log_x: bool = True, log_y: bool = True, x_base: int = 2, legend: bool = True, xlabel: str = 'batch size', ylabel: str = 'items / s', title: str | None = None, note: str | None = 'a rising line means the device is not yet saturated', figsize: tuple[float, float] | None = None) -> Axes

Plot throughput against batch size — the saturation picture.

Same data shape as :func:scaling_curve, different question. Wall time always rises with the batch; throughput is what says whether the device is working harder or just working longer, and a line that is still climbing at the right edge means the sweep stopped before saturation.

Parameters:

Name Type Description Default
series Mapping[str, Any]

{name: {batch size: [samples]}}.

required
x Sequence[float] | None

shared x values, when series holds bare sequences.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
highlight str | None

the series being argued for; it takes the path accent.

None
band bool

draw the min–max band.

True
log_x bool

log-scale x (base x_base).

True
log_y bool

log-scale y.

True
x_base int

base of the x log scale.

2
legend bool

draw a legend.

True
xlabel str

x axis label.

'batch size'
ylabel str

y axis label.

'items / s'
title str | None

axes title.

None
note str | None

caption under the axes; pass None to drop it.

'a rising line means the device is not yet saturated'
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.throughput_curve( ... {"cuda": {64: [1.2e5], 128: [2.1e5], 256: [2.4e5]}}, ... highlight="cuda", ... ) ax.get_xlabel() 'batch size (log)'

Source code in planviz/benchmarks.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
def throughput_curve(
    series: Mapping[str, Any],
    *,
    x: Sequence[float] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    highlight: str | None = None,
    band: bool = True,
    log_x: bool = True,
    log_y: bool = True,
    x_base: int = 2,
    legend: bool = True,
    xlabel: str = "batch size",
    ylabel: str = "items / s",
    title: str | None = None,
    note: str | None = "a rising line means the device is not yet saturated",
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Plot throughput against batch size — the saturation picture.

    Same data shape as :func:`scaling_curve`, different question. Wall time
    always rises with the batch; throughput is what says whether the device is
    working harder or just working longer, and a line that is still climbing
    at the right edge means the sweep stopped before saturation.

    Args:
        series: ``{name: {batch size: [samples]}}``.
        x: shared x values, when ``series`` holds bare sequences.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        highlight: the series being argued for; it takes the ``path`` accent.
        band: draw the min–max band.
        log_x: log-scale x (base ``x_base``).
        log_y: log-scale y.
        x_base: base of the x log scale.
        legend: draw a legend.
        xlabel: x axis label.
        ylabel: y axis label.
        title: axes title.
        note: caption under the axes; pass ``None`` to drop it.
        figsize: figure size when creating the axes.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.throughput_curve(
        ...     {"cuda": {64: [1.2e5], 128: [2.1e5], 256: [2.4e5]}},
        ...     highlight="cuda",
        ... )
        >>> ax.get_xlabel()
        'batch size (log)'
    """
    ax = scaling_curve(
        series,
        x=x,
        ax=ax,
        dark=dark,
        highlight=highlight,
        band=band,
        log_x=log_x,
        log_y=log_y,
        x_base=x_base,
        legend=legend,
        xlabel=xlabel,
        ylabel=ylabel,
        title=title,
        figsize=figsize or (6.8, 4.2),
    )
    if note:
        with style_context(dark) as tokens:
            _common.caption(ax, tokens, note)
    return ax

crossover_plot

crossover_plot(ratios: Mapping[str, Any], *, x: Sequence[float] | None = None, ax: Axes | None = None, dark: bool = False, highlight: str | None = None, baseline: float = 1.0, baseline_label: str = 'parity', log_x: bool = True, log_y: bool = True, x_base: int = 2, legend: bool = True, xlabel: str = 'agents', ylabel: str = 'baseline time ÷ candidate time', title: str | None = None, figsize: tuple[float, float] | None = None) -> Axes

Plot speedup ratios and the size at which the ranking flips.

A crossover chart is the only honest way to answer "is the GPU faster?" — the answer is a size, not a number. The parity line is drawn in faint because it is a reference, not a result; above it the denominator wins.

Ratios must come from instances both sides solved. Averaging a fast solver's successes against a slow solver's timeouts produces a speedup number that means nothing.

Parameters:

Name Type Description Default
ratios Mapping[str, Any]

{name: {x: [ratio samples]}} or {name: {x: ratio}}.

required
x Sequence[float] | None

shared x values, when ratios holds bare sequences.

None
ax Axes | None

draw into this axes instead of creating one.

None
dark bool

use the dark scheme.

False
highlight str | None

the series being argued for; it takes the path accent.

None
baseline float

y value of the parity rule.

1.0
baseline_label str

label annotated on that rule.

'parity'
log_x bool

log-scale x (base x_base).

True
log_y bool

log-scale y — a ratio axis should be symmetric about parity, and only a log axis is.

True
x_base int

base of the x log scale.

2
legend bool

draw a legend.

True
xlabel str

x axis label.

'agents'
ylabel str

y axis label.

'baseline time ÷ candidate time'
title str | None

axes title.

None
figsize tuple[float, float] | None

figure size when creating the axes.

None

Returns:

Name Type Description
The Axes

class:~matplotlib.axes.Axes drawn on.

Example

import matplotlib; matplotlib.use("Agg") import planviz ax = planviz.crossover_plot( ... {"prioritized": {8: [0.6], 16: [1.4], 32: [3.1]}}, ... highlight="prioritized", ... ) len(ax.lines) >= 2 True

Source code in planviz/benchmarks.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
def crossover_plot(
    ratios: Mapping[str, Any],
    *,
    x: Sequence[float] | None = None,
    ax: Axes | None = None,
    dark: bool = False,
    highlight: str | None = None,
    baseline: float = 1.0,
    baseline_label: str = "parity",
    log_x: bool = True,
    log_y: bool = True,
    x_base: int = 2,
    legend: bool = True,
    xlabel: str = "agents",
    ylabel: str = "baseline time ÷ candidate time",
    title: str | None = None,
    figsize: tuple[float, float] | None = None,
) -> Axes:
    """Plot speedup ratios and the size at which the ranking flips.

    A crossover chart is the only honest way to answer "is the GPU faster?" —
    the answer is a size, not a number. The parity line is drawn in ``faint``
    because it is a reference, not a result; above it the denominator wins.

    Ratios must come from instances **both** sides solved. Averaging a fast
    solver's successes against a slow solver's timeouts produces a speedup
    number that means nothing.

    Args:
        ratios: ``{name: {x: [ratio samples]}}`` or ``{name: {x: ratio}}``.
        x: shared x values, when ``ratios`` holds bare sequences.
        ax: draw into this axes instead of creating one.
        dark: use the dark scheme.
        highlight: the series being argued for; it takes the ``path`` accent.
        baseline: y value of the parity rule.
        baseline_label: label annotated on that rule.
        log_x: log-scale x (base ``x_base``).
        log_y: log-scale y — a ratio axis should be symmetric about parity,
            and only a log axis is.
        x_base: base of the x log scale.
        legend: draw a legend.
        xlabel: x axis label.
        ylabel: y axis label.
        title: axes title.
        figsize: figure size when creating the axes.

    Returns:
        The :class:`~matplotlib.axes.Axes` drawn on.

    Example:
        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz
        >>> ax = planviz.crossover_plot(
        ...     {"prioritized": {8: [0.6], 16: [1.4], 32: [3.1]}},
        ...     highlight="prioritized",
        ... )
        >>> len(ax.lines) >= 2
        True
    """
    with style_context(dark) as tokens:
        _, ax = _common.axes(ax, tokens, figsize or (6.8, 4.2))
        ax.axhline(baseline, color=tokens.faint, linewidth=1.0, zorder=1)
        ax = scaling_curve(
            ratios,
            x=x,
            ax=ax,
            dark=dark,
            highlight=highlight,
            band=True,
            log_x=log_x,
            log_y=log_y,
            x_base=x_base,
            legend=legend,
            xlabel=xlabel,
            ylabel=ylabel,
            title=title,
        )
        if baseline_label:
            ax.annotate(
                baseline_label,
                xy=(1.0, baseline),
                xycoords=("axes fraction", "data"),
                textcoords="offset points",
                xytext=(-4, 4),
                ha="right",
                fontsize=8.5,
                color=tokens.muted,
            )
    return ax

Output

planviz._common.save

save(figure_or_ax: Figure | Axes, path: str | Path, dpi: int = 200) -> Path

Save a figure (or the figure owning an axes) and return the path.

Figure functions never save on their own — this is the explicit call.

>>> import matplotlib; matplotlib.use("Agg")
>>> import planviz, tempfile, os
>>> ax = planviz.draw_grid([[0, 1], [0, 0]])
>>> out = planviz.save(ax, os.path.join(tempfile.mkdtemp(), "grid.png"))
>>> os.path.getsize(out) > 0
True
Source code in planviz/_common.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def save(figure_or_ax: Figure | Axes, path: str | Path, dpi: int = 200) -> Path:
    """Save a figure (or the figure owning an axes) and return the path.

    Figure functions never save on their own — this is the explicit call.

        >>> import matplotlib; matplotlib.use("Agg")
        >>> import planviz, tempfile, os
        >>> ax = planviz.draw_grid([[0, 1], [0, 0]])
        >>> out = planviz.save(ax, os.path.join(tempfile.mkdtemp(), "grid.png"))
        >>> os.path.getsize(out) > 0
        True
    """
    figure = getattr(figure_or_ax, "figure", figure_or_ax)
    target = Path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    figure.savefig(
        target,
        dpi=dpi,
        bbox_inches="tight",
        facecolor=figure.get_facecolor(),
    )
    return target