Specify a figure

Declare a publication figure as data: a mosaic of panels whose layers bind result-container fields to visual channels, rendered backend-independently through bsplot.

Part of the running example, where stage 6 adds several experiments, their analyses and their figures.

A figure in TVBO is data, not plotting code. A Figure is a mosaic of panels; each grammar panel binds fields of a result container to visual channels (mark + encoding), and the binding that says which data (Layer.used) doubles as the figure’s provenance edge. From that spec TVBO generates a self-contained plot.py through the bsplot adapter, so the plotting script becomes an inspectable, editable, FAIR replication artefact instead of a hand-written one-off.

Figures are the last declarative field of a study: a SimulationStudy carries a figures: list, closing the loop: a study is its experiments and the figures that read them. You can also render a standalone Figure.

Note

This page teaches how to write a figure spec. For the exhaustive field reference, see the schema (Figure, Panel, Layer), and for panels the grammar can’t express, Custom figure panels.

How a figure is built

A Figure nests three things; the rest are options documented in the schema.

Figure     → a mosaic of panels + look/size options
  Panel    → one cell; kind = cartesian | heatmap | image | custom
    Layer  → one mark bound to one data source (grammar panels)

A minimal figure

One panel, one line: the recorded variable V against time from an experiment’s result:

figures:
  - name: activity
    layout: "a"
    panels:
      a:
        kind: cartesian
        layers:
          - used:
              iri: tvbo:exp/MyStudy/exp-1   # the experiment whose results to read
              output: V                     # a recorded state variable in the container
            mark: line
            encoding:
              x: time
              y: V
        label: "Regional activity"

Render it (see Rendering):

tvbo figure render MyStudy -o figures/

Arranging panels: the layout mosaic

layout is a bsplot mosaic string: each letter is a panel key, a / (or newline) starts a new row, a repeated letter spans cells, and . leaves a gap.

layout: "aab/ccd"     # 'a' spans the top-left 2 cells, 'b' top-right; 'c' spans, 'd' bottom-right

The figure also owns physical size and journal styling, independent of the data:

width: 96.0
height: 76.0
dpi: 200
font_size: 8.0
style: [code/figures/mystudy.mplstyle]   # bsplot.style.use — names and/or .mplstyle paths
panel_numbers: true

Row/column ratios, panel-number format and placement, spine style, output format and the rest of the figure-level options are Figure attributes. Set them there; this page does not restate them.

Panels: grammar vs. escape hatch

kind Draws Body
cartesian line / scatter / rule / band marks layers: (grammar)
heatmap a 2-D array field layers: with mark omitted (implied)
surface per-vertex values painted on a mesh layers: + surface: {…}
volume per-region values projected through a labelled volume layers: + volume: {atlas: …}
network the connectome as a node-link graph, nodes coloured by a layer layers: + network: {connectome: …}
image an external file path:
custom a registered callable fn(fig, ax, ctx) render:escape hatch

cartesian/heatmap are grammar-driven; surface, volume and network are built in and need no code_modules; image/custom are peer escape hatches. Every kind shares the label, legend, annotations, and honest-placeholder machinery. The full kind list, and each one’s options, are PanelKind and Panel.

The brain kinds

Three kinds draw the same per-region quantity on three different geometries, and all three place values by label rather than by array position — a region’s value cannot land on its neighbour:

b:
  kind: network                     # the graph the model ran on
  layers: [{used: {experiment: 1, output: integration, sel: {variable: V}}}]
  network: {connectome: tvbo:DesikanKilliany, projection: axial, colormap: magma}
c:
  kind: surface                     # the same values on a cortical mesh
  layers: [{used: {experiment: 1, output: integration, sel: {variable: V}}}]
  surface: {atlas: tvbo:atlas/DesikanKilliany, template: fsaverage, hemi: both, view: dorsal}

A network panel needs the connectome named — a curated IRI or a path beside the study — because the panel draws the network, not the result; edge_percentile (default 92) keeps a dense connectome from drawing as a filled square, and projection picks the anatomical plane the region centres flatten onto.

Each kind’s options are the object named after the kind, and only a panel of that kind may state one. view on a surface is one of six camera positions; view on a volume is one of three anatomical planes; both are enums, so the wrong vocabulary is a validation error rather than a figure that renders from a default nobody was told about. The colour scale is the same five words wherever a field is painted — colormap, vmin, vmax, symmetric, percentile — on a surface, a volume, a graph and a colour bar alike.

Binding data to a panel: layers

A layer is one mark bound to one data reference. This is where the grammar earns its keep, and where provenance is recorded.

used: which data, and the provenance edge. The used block points at a result-container field and is typed prov:used, so declaring where a mark’s data comes from is the provenance record, so every plotted number stays traceable to the run that produced it:

used:
  iri: tvbo:exp/MyStudy/exp-3   # the experiment / result / dataset the data comes from
  output: delta_omega           # a recorded state var, an observation__<name>, or an external var
  sel:                          # xarray .sel — LABEL-keyed, never positional
    KuramotoInertia.K: [270.0, 800.0, 1300.0]

The label-keyed sel is what keeps figures free of the positional-reshape mislabelling bug.

transform: keep compute off the plotting side. A presentation-only reduction is a last resort. Prefer, in order: declare an Observation upstream so the container is already plot-ready → a declarative postprocessing in the tvbo schema → a registered callable fn(da) -> da named in transform:. The figure spec stays presentation-only, so the emitted plot.py hides no analysis.

mark, encoding, style. Channels bind to dim/coord/var names (keyed, never positional); style carries portable intent plus an opts: matplotlib passthrough. See Layer for the full channel and style list:

mark: line
encoding: {x: KuramotoInertia.K, y: delta_omega}
style: {color: "#1f77b4", opts: {linewidth: 1.3, linestyle: "--"}}

A real grammar panel

From Taher2019.yaml, a cartesian panel overlaying three layers (up-sweep line, down-sweep line, and sampled scatter markers) that all read the same container field:

panels:
  a:
    kind: cartesian
    layers:
      - used: {iri: tvbo:exp/Taher2019/exp-3, output: delta_omega}
        transform: up_branch          # a registered per-study reduction (fn(da)->da)
        mark: line
        encoding: {x: KuramotoInertia.K}
        style: {color: "#1f77b4", opts: {linewidth: 1.3, label: up-sweep}}
      - used: {iri: tvbo:exp/Taher2019/exp-3, output: delta_omega}
        transform: down_branch
        mark: line
        encoding: {x: KuramotoInertia.K}
        style: {color: "#1f77b4", opts: {linewidth: 1.3, linestyle: "--", label: down-sweep}}
      - used:
          iri: tvbo:exp/Taher2019/exp-3
          output: delta_omega
          sel: {KuramotoInertia.K: [270.0, 800.0, 1300.0]}   # mark the three sampled K
        transform: up_branch
        mark: scatter
        encoding: {x: KuramotoInertia.K}
        style: {opts: {facecolors: none, edgecolors: k, s: 49}}

What a panel’s axes say

Everything about how a panel’s axes are drawn is a slot on the panel. There is no options bag for it, and a name the schema does not know is a validation error rather than a keyword that reaches matplotlib and raises there:

d:
  kind: cartesian
  xlabel: "Target [Hz]"
  ylabel: "Simulated [Hz]"
  xlim: [6, 12]
  ylim: [6, 12]
  aspect: equal                     # one unit of x is one unit of y
  legend: upper left
  rules:
    - {orientation: diagonal, at: [1.0, 0.0]}     # the agreement line
Family Slots
What the axes say xlabel, ylabel, zlabel, title, and *_pad / *_side for where each label sits
How far they run xlim, ylim, zlim, xscale, yscale
What shape they are aspect (the data), box_aspect (the frame), invert_x / invert_y / invert_z, frame
Their ticks xticks, yticks, nbins, tick_prune, tick_size, tick_length, *tick_rotation, *tick_format, *tick_side, hide_*ticklabels
Marks that carry a colour rules, regions, camera, annotations
The key legend

A limit is a declared pair of numbers, not text: two panels a figure asks the reader to compare must not end up on whatever ranges their own data happened to reach, and a range written as [1.0e-4, 1.0e4] reaches the axis as two numbers rather than as one number and one string. A title is the panel’s own words; the letter a journal prints beside it is number, which the layout places and sizes.

The legend is one slot with three spellings. legend: true draws a key wherever the backend finds room, legend: upper left fixes the corner, and the object states the rest — so a panel that starts with a corner and later wants two columns keeps the word it had:

legend: {loc: lower right, columns: 2, title: Route, frame: true}

Whether the key is boxed is the theme’s legend_frame unless the panel says otherwise; TVB-O’s own theme draws it unboxed. kind: legend is a different thing — a standalone key occupying its own cell of the mosaic, for a convention several panels share.

opts: is a custom callable’s keywords, and nothing else — plus the one thing that cannot be a slot: a row.<name> / col.<name> entry on a grid, which supplies one value per row or column of any directive. Everything else a panel is drawn from is declared. A spec written against the older flat spelling is rewritten by python scripts/migrate_panel_marks.py <study>, which reports the files it will not touch by itself rather than reflowing a hand-authored spec around a ten-line change.

Because those keywords belong to a callable, a name is only retired for the kind that owns it: color, labels and cmap are a surface panel’s options and good keywords for a custom one, so a custom panel keeps every word it has always taken.

The colour bar, and each kind’s own options

A panel’s colour bar is one object, not nine loose options that could style a bar the panel never draws:

g:
  kind: heatmap
  colorbar: {label: "FC", ticks: [-0.3, 0.3, 0.9], decimals: 2}

show overrides the default, which is on for a heatmap — unreadable without one — and off for a scatter shaded by a third quantity, since a row of those conventionally shares one bar. kind: colorbar is that shared bar: a standalone panel in its own cell of the mosaic, and the only case that states its own colormap and limits rather than taking them from the field beside it.

The other built-in kinds work the same way. A grid panel declares its tiling in grid:, and kind: legend states its entries in the same legend: slot every other panel uses for its key:

e:
  kind: grid
  grid: {ncols: 3, col_labels: [Initial, Target, Fitted], wspace: 0.05}
  cell: {kind: volume, volume: {atlas: tvbo:atlas/DesikanKilliany, view: sagittal, colormap: cividis_r}}

Animating a figure

Some figures are about a process, and a still of one is a snapshot with the subject cut out. Declaring animation: turns the figure into a movie over one named dimension. Nothing else changes: the mosaic, the panels, the sizing and the styling are the frame, and the still and the movie cannot disagree about what they show because they are the same composition at different positions.

figures:
  - name: first_simulation
    layout: "ab/cc"
    animation: {over: time, frames: 40, fps: 12, format: gif, still: 20}

over names the dimension by name, never by position. frames subsamples it — unset draws one frame per integration sample, which for a 600 000-step run is not a movie anyone wants — and fps sets playback separately, so how much of the run you show and how fast it plays are two decisions. format is gif (needs nothing installed) or mp4 (needs ffmpeg); the saved file takes that extension in place of the still’s, so a figure is never written as both. still: names one frame to also save as an image, for the printed page a movie cannot reach.

Each layer then has a part to play, and in the common case none of them has to say so:

frame: The layer Default for
slice advances with the frame a layer whose data carries over
static is drawn whole in every frame a layer whose data does not
cursor draws a rule at the current frame’s coordinate nothing — always declared

So a brain map animates by declaring nothing, and a time-course panel that should not scroll away says frame: static and gets a moving cursor from a second layer:

c:
  kind: cartesian
  layers:
    - used: {experiment: 1, output: integration, sel: {variable: V}}
      mark: line
      frame: static                 # the whole course, in every frame
      encoding: {x: time}
    - used: {experiment: 1, output: integration, sel: {variable: V}}
      mark: rule
      frame: cursor                 # ...and the instant the other panels are at
      encoding: {x: time}
      style: {color: "#111111"}

Every animated layer is measured and the shortest wins, so two panels reading different runs cannot end up at different instants. The frame count is a property of the figure, not of a panel.

Note

A frame is a full re-composition of the mosaic, which is what lets every panel kind animate — including the ones that swap their own axes. It also means a movie costs roughly one still per frame to render, so declare frames: deliberately.

Rendering

A Figure is compiled, not interpreted: the bsplot adapter resolves the layer context in Python and a Mako template emits a self-contained plot.py. That script is the deliverable: inspectable, editable, and re-runnable on its own against the containers the study already wrote.

# Render every figure in a study (or a standalone Figure YAML)
tvbo figure render MyStudy --out figures/ --base-dir .
from tvbo.adapters import bsplot
# `figure` is a datamodel Figure (e.g. study.figures[0])
bsplot.render(figure, base_dir=".", outfile="figures/activity.png")
bsplot.render_code(figure)   # the plot.py source, without executing it

Because a figure’s used edges are its data dependencies, a figure is also a workflow step: tvbo workflow emits a render rule per figure (inputs = the used containers), scheduled after the experiments it reads. Heavy renders request their own resources via the figure’s workflow_overrides. See Reproducible workflows.

When the grammar isn’t enough

A hand-drawn inset, a covariant Lyapunov vector, a per-node montage: anything the grammar cannot express is a kind: custom panel that names a callable the study ships in its code_source. The generated plot.py imports the study’s code_modules so the panel resolves whether you render on a laptop or an HPC node. See Custom figure panels.

Honest placeholders

When a panel’s data is missing or degenerate, set placeholder: so the render draws a labelled placeholder instead of erroring. It is never a replot of the paper’s source data:

panels:
  c:
    kind: cartesian
    placeholder: "awaiting HCP S900 SC"
    layers: [...]

See also