Filtering And Selecting¶
An extracted tree becomes a question only once rows are picked out of it: the hits of one event, the ones inside the detector, the ones a prompt gamma left. The package names those questions, on the pandas view of the data.
from pathlib import Path
from opengate_gate_tree import GammaType, read_hits_trees
frame = read_hits_trees(Path("simulation.root")).to_dataframe()
in_the_scanner = frame.gate.in_cylinder((0, 0), radius=500.0, inner_radius=409.0)
prompt = in_the_scanner["gammaType"].gate.is_gamma_type(GammaType.PROMPT)
energies = in_the_scanner["edep"][prompt]
What Is Added, And What Is Not¶
Everything here works on a pandas.Series or a pandas.DataFrame, and pandas
already answers most questions about a column. Comparing, isin, combining
masks with & and | — none of that is restated:
frame[frame["PDGEncoding"] == 22] # gammas
frame[frame["trackID"].isin([1, 2])] # two tracks
A filter earns its place by naming something the data means: a closed range, a shape in the geometry of the scanner, the identity of an event, the meaning of a code GATE wrote. Anything a line of pandas already says is left to pandas.
The range is the one place where a call of pandas is wrapped rather than left
alone: is_in_range is Series.between under another name. It is here because
it comes in a pair with in_range, like every other filter, and because the
shapes are built out of it — a box is three ranges, and the ends of a cylinder
are a fourth.
Every Filter Comes In Two¶
The same question can be asked in two ways, and both are needed, so every filter exists twice under two names:
Name |
Answers with |
What it is for |
|---|---|---|
|
a boolean column |
combining conditions, indexing another column |
|
what was asked about, narrowed |
chaining one selection onto the next |
The mask half is one word wherever it appears. The other half is named after
how the question reads — in_sphere, by_run, with_decay_metadata,
select_by_process — so the four prefixes are worth knowing:
Prefix |
Asks about |
Example |
|---|---|---|
|
a range or a shape |
|
|
an identifier |
|
|
metadata a row carries |
|
|
the meaning of a code |
|
inside = frame.gate.is_in_sphere((0, 0, 0), 500.0) # a mask
rows = frame.gate.in_sphere((0, 0, 0), 500.0) # the rows
The mask is the more useful of the two whenever a question has several parts. Build the conditions, combine them, and cut the rows once:
energetic = frame["edep"].gate.is_in_range(0.2, 0.511)
first_slice = frame["time"].gate.is_in_range(0.0, 0.02)
selected = frame[energetic & first_slice]
Numbers are read in the units of the file, which are the ones GATE wrote: millimetres, seconds and MeV. Nothing is converted anywhere in the package.
A mask keeps the length and the index of what it was built from, so masks of one frame always combine. Rows returned by a selection keep the index they had, which is what lets a hit be traced back to the entry it came from.
Ranges¶
frame["edep"].gate.is_in_range(0.2, 0.511) # a mask
frame["edep"].gate.in_range(0.2, 0.511) # the values
frame["edep"].gate.in_range(0.2, 0.511, inclusive="neither") # ends left out
Both ends belong to the range unless inclusive says otherwise. The vocabulary
is the one of pandas.Series.between — "both", "neither", "left",
"right" — so there is no second convention to remember, and neither is there
a second behaviour: a missing value falls outside the range, and a column that
cannot be compared against the ends at all, such as text against numbers,
raises the TypeError pandas raises rather than answering.
Shapes¶
A hit has a position, and a question about a detector is a question about a shape. Three shapes are described the same way: by where they sit and how big they are.
Filter |
Described by |
Reads |
|---|---|---|
|
centre, side lengths |
three columns |
|
centre, radius |
three columns |
|
centre of the axis, radius, ends |
three columns |
Each side of a box reaches half its length either way from the centre, which is how a volume is described in a GATE macro as well:
frame.gate.in_box((0, 0, 0), (3.2, 20.0, 3.2)) # one crystal at the origin
frame.gate.in_box((0, 0, 0), 100) # a cube of side 100
A sphere and a cylinder take a radius. A cylinder takes the ends of its axis too, and an inner radius that turns it into a ring, which is how a layer of a scanner is usually asked for:
frame.gate.in_cylinder(
(0, 0), # where the axis crosses the xy plane
radius=500.0, # outer radius, in the units of the file
z_range=(-530.0, 530.0), # the ends, unbounded when omitted
inner_radius=409.0, # what makes it a ring
)
The cylinder runs along the third of the columns it reads. Another axis is a matter of naming the columns in another order rather than of another parameter:
frame.gate.in_cylinder((0, 0), 500.0, columns=("posX", "posZ", "posY"))
Note
Surfaces belong to their shape: a hit sitting exactly on a face, on a sphere or on the wall of a ring counts as inside. The other convention would drop hits on a boundary, and a simulation puts them there — a crystal is where energy is deposited, and its surface is where a gamma enters it.
Which Columns A Shape Reads¶
A “Hits” tree carries three positions per entry: where the hit happened, where it happened inside its volume, and where the gamma was born. The shapes read the first of them unless told otherwise:
Columns |
What they hold |
|---|---|
|
where the hit happened, in the frame of the world |
|
the same, in the frame of the volume |
|
where the gamma was born |
from opengate_gate_tree import POSITION_COLUMNS
POSITION_COLUMNS # ('posX', 'posY', 'posZ'), the default
frame.gate.in_sphere((0, 0, 0), 25.0, columns=("sourcePosX", "sourcePosY", "sourcePosZ"))
A shape always reads three columns. The centre gives one value per column it is
centred in, in the same order — three for a box and a sphere, two for a
cylinder, whose third column is its axis. Anything else raises ValueError,
and so does a radius, a side or a coordinate that is not a finite number: a
shape built from nan would answer “no rows”, which is the answer hardest to
tell from a real one. A column the frame does not hold raises KeyError.
Runs And Events¶
frame.gate.by_run(2) # the rows of a run
frame.gate.by_event(2, 5) # the rows of one event of that run
frame.gate.is_from_event(2, 5) # the same, as a mask
An event is named by both identifiers. GATE numbers events within a run, so
a file holding three runs holds an event 5 in each of them, and the three are
different decays. That is why there is no filter taking the event identifier
alone: frame["eventID"] == 5 is one comparison, and it should look like the
guess it is. See Event Identifiers.
What A Gamma Was¶
The branches a PositroniumSource writes hold integers, and the selectors take
the members that name them:
from opengate_gate_tree import DecayType, GammaType, SourceType
frame["sourceType"].gate.is_source_type(SourceType.ORTHO_POSITRONIUM)
frame["decayType"].gate.select_by_decay_type(DecayType.DEEXCITATION)
frame["gammaType"].gate.is_gamma_type(GammaType.PROMPT, GammaType.ANNIHILATION)
Several members can be given at once, and the answer covers all of them. Giving
none raises ValueError: an empty selection is a call that means nothing.
Note
These four selectors are asked about one column, so select_by_* answers
with the values of that column — a decayType column holding nothing but
DEEXCITATION, which is what to count or to run value_counts() on. Rows are
selected through the mask, which is why the chains on this page use is_* and
index the frame with it:
deexcitation = frame["decayType"].gate.is_decay_type(DecayType.DEEXCITATION)
rows = frame[deexcitation]
```{warning}
Each selector takes the members of its own class. The classes share their
numbers — a source type of 2 is a para-positronium and a gamma type of 2 is an
annihilation gamma — so a member of the wrong class would select the right rows
for the wrong reason, or the wrong rows outright. Passing one raises
`ValueError` naming what was passed.
Rows carrying the decay metadata of such a source are told from the rest by
decayIndex, which the frame answers about directly:
frame.gate.with_decay_metadata() # the rows
frame.gate.has_decay_metadata() # the mask
The answer is narrower than “written by a PositroniumSource”: such a source
numbers every gamma it emits, including the ones from a direct annihilation
component. What a gamma itself was is said by sourceType. See
PositroniumSource Data.
Both read decayIndex as the whole numbers GATE writes there, and raise
ValueError when the column holds something else. A frame does not have to
come straight out of to_dataframe(): a round trip through CSV, or a
concatenation that introduced a missing value, turns the column into floating
point numbers, where the value standing for “no metadata” can no longer be
compared for.
The Process That Made A Hit¶
processName is text, so its selector takes names as GATE writes them:
frame["processName"].gate.is_process("Compton")
frame["processName"].gate.select_by_process("Compton", "PhotoElectric")
The names are not checked against a list. GATE builds differ in the physics lists they were compiled with, and a name this package had never heard of would be refused for no reason.
The gate Namespace¶
Importing opengate_gate_tree registers a gate accessor on pandas.Series
and on pandas.DataFrame. Which of the two carries a filter follows from what
the filter needs to know: a range is a question about one column, a shape about
three of them at once, and the identity of an event about two.
import opengate_gate_tree # registering the namespace is what the import does
frame.gate.by_run(0) # frame in, frame out
frame["edep"].gate.in_range(0.2, 0.4) # column in, column out
Because each answers with what it was given, selections chain:
energies = (
frame.gate.by_event(1, 5)
.gate.in_sphere((0, 0, 0), 500.0)["edep"]
.gate.in_range(0.0, 0.511)
)
Every method calls the function of the same name and adds nothing, so the same work reads either way. The functions are exported from the package root, and are what to reach for when a filter is passed around rather than called:
from opengate_gate_tree import in_range, in_sphere
in_sphere(frame, (0, 0, 0), 500.0)
in_range(frame["edep"], 0.2, 0.4)
Note
gate is registered on classes the whole process shares. Should the name
already be taken, pandas says so with a warning, which the package does not
silence: it reports a real collision in somebody’s code.
Filtering And What Follows¶
A selection is a pandas.DataFrame, and everything downstream of it takes one:
from opengate_gate_tree import GateTree, OutputFileFormat, TreeData, write_tree
selected = frame.gate.in_cylinder((0, 0), 500.0, inner_radius=409.0)
data = TreeData.from_dataframe(GateTree.HITS, selected)
write_tree(data, Path("out/in-the-ring.hdf5"), OutputFileFormat.HDF5)
from_dataframe drops the index, so a written file holds the rows that were
selected and nothing about which entries they had been. Identifiers are what
carries that, and they are written as GATE wrote them.
Warning
A statistics report computed after a selection describes the selection, not the
file. That is usually the point — but runs and events counted on rows that
a filter has already removed say how much of the data survived it, not how much
the simulation produced.