Skip to content

Histograms and plots

A simulation that has run has produced numbers, and the numbers are the point. This is the path from a count to something on screen:

what fills it  -->  Histogram   -->  RootHistogram  -->  a pad on a canvas
  (a recipe,        PointSet         RootScatter
   a script,       (the data)       (this view of it)
   a loop)

Three objects, and the split between them is the thing to understand: the data object is the measurement, the ROOT proxy is one view of it, and the canvas decides how many views fit side by side. Two panes can show one histogram with different axis scales; they cannot disagree about how many bins it has.

A measurement is one of two shapes, and each has its proxy:

Data Proxy Draws
Histogram — counts in bins RootHistogram a curve, or a colour map
PointSet — x against y RootScatter one point per pair

Both live in Math and neither knows anything about particles, runs or events: a Histogram is counts in bins and a PointSet is pairs of numbers, whether they came from a Geant4 run or a spreadsheet.

A pad can hold more than one curve — two spectra compared, a measurement against its fit. That is a group, and it is the same three objects with one more nested in between.


Histogram — the measurement

uLib::Histogram (src/Math/Histogram.h) counts weighted points in any number of dimensions. Its axes are three parallel lists, one entry per axis:

Property Meaning
BinCounts bins per axis — 50 is 1D, 50, 50 is 2D, and so on
AxisMin, AxisMax the low and high edge of each axis
AxisTitles comma-separated names, "energy,angle"
Title what the histogram is of

The number of axes is however many entries the lists agree on; a list of the wrong length makes the histogram unusable and GetBuildError() says so rather than guessing at an intent.

Bins live in one flat vector of the product of the counts, so a 3-axis histogram is a real 3-axis histogram and not three projections. Ranges are half-open, [min, max).

Points outside the range are counted, not clamped. GetUnderflow() and GetOverflow() report them. A histogram whose range was set too narrow should say so; piling the tail into the last bin invents a peak that is not there.

Filling is mutex-guarded, because the thing filling it is often a Geant4 worker thread.

Histogram is bound for Python, so the thing filling it is usually a recipe with the histogram on an output port:

Spectrum = OutputPort("Spectrum")

Spectrum.Fill(2150.0)               # 1D, weight defaults to 1
Spectrum.Fill2(2150.0, 0.03)        # 2D
Spectrum.FillPoint([x, y, z], w)    # any number of axes

Each returns False when the point fell outside every bin, which is how a script can notice its range is wrong without reading the overflow counts.

Changing the bins, and filling it again

The axis lists are edited in the Properties panel as text: 40, or 40 20 (commas are accepted too). Changing BinCounts, AxisMin or AxisMax empties the histogram, since the old contents belong to the old bins. It then emits Updated(), so any plot of it redraws, followed by UpdateRequested(). A title change does neither.

UpdateRequested() asks whoever fills the histogram to fill it again. A recipe (or any Algorithm) with AutoRun set answers it when the histogram is on one of its output ports. The recipe then runs, refills the histogram for the new bins, and when it finishes announces Updated() on its outputs, so the canvas redraws with the new contents. This needs no extra wiring: the Recipe --(Histogram) wire that the recipe already fills through is the link.

  • A recipe that re-bins its own output while it is running is not run a second time.
  • SetAxes() asks for a refill once, however many axes changed.
  • Loading a saved scene asks for no refill: the saved contents come back with it.
  • Every run of the filling algorithm starts from empty. When an algorithm starts a run, it emits RefillStarted() on its output objects, and a Histogram (or a PointSet) with ResetOnRefill on — the default — empties itself there. A re-run therefore replaces the plot instead of adding to it. Turn ResetOnRefill off to accumulate over runs on purpose.

RootHistogram — one view of it

uLib::Root::RootHistogram (src/Root/RootHistogram.h) is the ROOT proxy: it builds a TH1D or TH2D from a histogram on its Source input port and keeps the two in step. It is the same split the VTK side uses, where a Prop3D draws a solid without the solid knowing it is drawn.

A histogram may have more axes than a plot can show. AxisX and AxisY pick which two; AxisY = -1 is a one-dimensional plot. Any remaining axes are summed over, which is the honest thing to do with a dimension you are not looking at, and the plot's title says it is happening.

Refresh() copies the bins across, and it is deliberately not automatic: the histogram is filled from worker threads and ROOT is not thread-safe, so the copy happens on the thread that owns the canvas, when that thread asks.

In gcompose, a canvas pane does the asking. When a histogram, a point set or a plot in the scene emits Updated(), the pane schedules one redraw on its own thread. Several announcements before that redraw still produce a single one.

Display properties, and why they are here

Property
Visible whether this plot is on the canvas — the eye in the context tree
Title, XTitle, YTitle labels; empty inherits the histogram's own
LogX, LogY, LogZ log scales; z is the colour axis of a 2D plot
LineColor, LineWidth, FillColor ROOT colour indices, 0 for no fill
DrawOptions passed to TH1::Draw — "HIST", "COLZ", "E"

These are on the plot, not on the histogram, and the boundary is worth stating: how many bins there are is a fact about the measurement, and whether its y axis is logarithmic is a fact about this picture of it. In gcompose they land in the display properties panel for exactly that reason — the same panel that holds a solid's colour, and not the one that holds its size.

Empty means inherit rather than blank, so a plot that has never been styled shows the histogram's own titles instead of an unlabelled axis.


PointSet — x against y

A histogram answers how many, at each value. A PointSet answers what went with what, keeping every pair rather than summing them into bins — which is the whole reason to use one. Where a muon crossed a probe against how fast it was going; a calibration against temperature.

Property
Title, XTitle, YTitle what the points are of, and what x and y are
MaxPoints stop keeping points past this many; 0 means no cap

Filled the way a Histogram is — a point at a time from a recipe or a loop, or all at once from vectors somebody already has — and mutex-guarded for the same reason: the thing filling it is often not the thing drawing it.

# a point at a time
Points.Add(u, v)
Points.Add(u, v, sigma_v)          # with an error bar on y
Points.Add(u, v, sigma_u, sigma_v) # bars both ways

# or all at once
Points.SetPoints(xs, ys)
Points.SetPoints(xs, ys, sigmas)

SetPoints replaces rather than appends — handing over two lists says what the set is — and refuses lists of different lengths outright. Truncating to the shorter one would pair x with the wrong y, and that is precisely the plot nobody can tell is wrong.

Error bars are all-or-nothing: a set has them once anything has been added with them, and HasErrors() says so. That is what lets a plot choose between bars and no bars without being told. Only whatever filled the set knows whether there is an uncertainty, and a set filled without one should not be drawn as though the uncertainty were zero.

MaxPoints is a guard against a loop that fills without bound. What it refuses is counted rather than silently missing — GetDroppedCount().

RootScatter — the proxy that draws it

Attach a PointSet to its Source port; there is nothing else to configure, because the set already is two columns of numbers.

Property
MaxDrawn at most this many points are drawn — distinct from the set's MaxPoints, which is how many are kept
DrawOptions after the axis letter: "P", "PL" to join, "PZ" for flat-ended bars
MarkerColor, MarkerStyle, MarkerSize 20 is a filled circle, 7 a single pixel

Past MaxDrawn the points are thinned by taking every nth, not the first n. The first n are the beginning of whatever filled the set, which is a different population from all of it. The title says when it happened: "(every 10th of 100 points)".

The axis labels come from the set unless the plot overrides them, exactly as a RootHistogram takes its labels from its Histogram.

Getting from a probe to a scatter

A PlaneProbe produces an EventTable — rows of named columns, one per crossing. That is a Geant readout type, not a plotting one, so the way to a scatter is the same recipe step that gets from a table to a histogram:

Events = InputPort("Events")
Crossings = OutputPort("Crossings")   # a PointSet

def run(ctx):
    Crossings.SetPoints(Events.Column("u"), Events.Column("v"))

The canvas

A ROOT-canvas pane (Root/QCanvas) draws a grid of plots.

Nothing is plotted until it is switched on. A context may hold a dozen histograms, and a canvas showing all of them by default would be unreadable the moment a second one appeared. So the eye column in the context tree is the control: click it next to a histogram and it appears on the active canvas, click it again and it goes. Selecting a histogram does not show it — clicking through a tree would otherwise rearrange every canvas behind it — it only offers that plot's display properties for editing, which is the reason to select one.

The pane makes a proxy for any plain measurement on demand — a RootHistogram for a Histogram, a RootScatter for a PointSet — one per pane. That is what lets two canvases show the same histogram with different scales. A RootHistogram placed in the scene deliberately is used as it is and not shadowed by a second, so it is shared: somebody made it on purpose.

One pad per plot — but a plot may be several curves. Anything nested under a plottable is drawn into its pad rather than taking one of its own, so the list the canvas works from is the top-level plottables. See groups.

Arranging the plots

Two properties in the pane's own settings, under Canvas:

Property
Canvas Rows rows of pads
Canvas Columns columns of pads

One by one is a single plot filling the pane. More pads than visible plots leaves the spare ones blank. More plots than pads leaves the extra ones undrawn rather than shrinking everything to fit — a plot too small to read is not an improvement on an absent one — and GetOverflowCount() says how many were left out. Both are saved with the layout (gcompose::Viewport class version 2), so a workspace reopens arranged as it was left.


A group is one pad of several things

Two spectra compared are one picture. Side by side in two pads, each autoscaled to itself, a factor of two in rate looks like no change and a shifted peak is something the reader has to measure with a ruler. So plottables can be drawn over each other, and nesting is how you say so:

RootPlotGroup  "before and after the iron"
  |- RootHistogram  --> Histogram "before"
  `- RootHistogram  --> Histogram "after"

Nothing is wired and no list is kept — putting one plot inside another is the entire gesture. A RootPlotGroup is a plottable with no data of its own, which is what makes it the right thing to nest under when neither curve is the main one; nesting a plot directly inside another plot groups them just the same.

The two eyes

Where Property Default Means
top level Visible off this has a pad on the canvas
inside a group Included on this is one of the curves in that pad

The defaults are opposite on purpose. A context may hold a dozen histograms, so nothing gets a pad until you ask; but putting something in a group is already the act of asking for it to be in that plot, and the eye there is for taking one back out. The context panel shows both as the same eye, and the tooltip says which one you are clicking.

What the group decides

Everything about the pad, applied after the curves are on it so it wins over whatever each one brought: Title, XTitle, YTitle, LogX/LogY/LogZ, and XMin/XMax/YMin/YMax — a maximum at or below the minimum means "whatever fits", which is the default. Setting a shared y range on the group is usually the difference between a comparison and two pictures.

Two more, because an overlay has problems a single plot does not:

  • AutoColor gives each member that has no colour of its own a different one. Three black curves over each other are one black shape. A member whose LineColor you have changed keeps it — this fills gaps, it does not override choices.
  • ShowLegend draws a key naming each member. Nothing is drawn on a pad with one thing on it.

What cannot share a pad

Not every plot can be drawn over every other. Each plottable declares an overlay class, the pad takes the class of the first thing drawn on it, and anything that disagrees is left out:

Class What it is Shares a pad with
Curve a value against one axis — a 1D RootHistogram other curves
Surface a value over two axes, drawn as a map — a 2D RootHistogram nothing, itself included
Points individual points in a plane — a RootScatter other point sets
Nothing no data: a group, or a plot that cannot build —

A scatter and a binned spectrum have different things on their y axis — a position against a count — so one set of labels cannot be true of both, and Points and Curve do not mix. A colour map covers its pad, so two surfaces do not either: the second would not be an overlay, it would just be the second one. Two scatters share a pad quite happily.

A plot that cannot build answers Nothing rather than the class it would have had: a RootScatter whose set has no points yet, a RootHistogram whose AxisX is past the end of its source. Otherwise an empty scatter sitting at the top of a group would classify the pad as points and every spectrum below it would be refused from its own plot. The pad takes its class from the first thing that actually draws.

Refusals are counted, not silent. GetExcludedCount() says how many members were left out of the last draw and GetPlotError() names them — in gcompose that is the group's Problem row, next to Members. A pad whose axes are true of only half of what is on it is a wrong plot that looks like a right one, which is worse than a pad with less on it.

Something that was refused wants a pad of its own: drag it out of the group, and Canvas Rows/Canvas Columns put the two side by side.


End to end

A plane probe watching a beam, a recipe deciding what the crossings mean, and a histogram of the answer — see A plane probe for the probe end:

PlaneProbe --(EventTable)--> Recipe --(Histogram)--> RootHistogram --> canvas

Wire it up, switch the eye on next to the histogram, set Canvas Rows to 2 if there is a second one to compare it with, and run.

If the second one is the same quantity measured somewhere else, it belongs on the same axes rather than in a second pad: make a RootPlotGroup (Add ▸ Root ▸ RootPlotGroup), drag both plots into it, and switch the group's eye on instead.

If it is a different kind of measurement — a scatter of where they crossed next to a spectrum of how fast they were going — it needs its own pad, and Canvas Rows is what puts the two one above the other.

src/Root/examples/01_plots_on_one_pad.cpp does all of that, and is built and run by the test suite: two spectra before and after a block of iron, a shared y range, a legend, a scatter that gets refused from their pad and drawn on its own, and a hit map that gets refused from everything. It writes 01_plots_on_one_pad.gcx, which opens in gcompose with the group already built.