#!/usr/bin/env python3
##########################################################################
# basf2 (Belle II Analysis Software Framework) #
# Author: The Belle II Collaboration #
# #
# See git log for contributors and copyright holders. #
# This file is licensed under LGPL-3.0, see LICENSE.md. #
##########################################################################
"""
The core classes of the skim package are defined in ``skim.core``: ``BaseSkim`` and
``CombinedSkim``.
* ``BaseSkim`` is an abstract base class from which all skims inherit. It defines
template functions for a skim, and includes attributes describing the skim metadata.
* ``CombinedSkim`` is a class for combining ``BaseSkim`` objects into a single steering
file.
"""
from abc import ABC, abstractmethod
import warnings
import basf2 as b2
from modularAnalysis import applyCuts, summaryOfLists
from skim.registry import Registry
from skim.utils.flags import InitialiseSkimFlag, UpdateSkimFlag
from skim.utils.testfiles import get_test_file
from skim.utils.misc import _hashable_list
[docs]
class BaseSkim(ABC):
"""Base class for skims. Initialises a skim name, and creates template functions
required for each skim.
See `writing-skims` for information on how to use this to define a new skim.
"""
NoisyModules = None
"""List of module types to be silenced. This may be necessary in certain skims in
order to keep log file sizes small.
.. tip::
The elements of this list should be the module *type*, which is not necessarily
the same as the module name. The module type can be inspected in Python via
``module.type()``.
.. seealso::
This attribute is used by `BaseSkim.set_skim_logging`.
"""
TestSampleProcess = "mixed"
"""MC process of test file. `BaseSkim.TestFiles` passes this property to
`skim.utils.testfiles.get_test_file` to retrieve an appropriate file location.
Defaults to a :math:`B^{0}\\overline{B^{0}}` sample.
"""
MergeDataStructures = {}
"""Dict of ``str -> function`` pairs to determine if any special data structures
should be merged when combining skims. Currently, this is only used to merge FEI
config parameters when running multiple FEI skims at once, so that it can be run
just once with all the necessary arguments."""
ApplyHLTHadronCut = False
"""If this property is set to True, then the HLT selection for ``hlt_hadron`` will
be applied to the skim lists when the skim is added to the path.
"""
produce_on_tau_samples = True
"""If this property is set to False, then ``b2skim-prod`` will not produce data
production requests for this skim on taupair MC samples. This decision may be made
for one of two reasons:
* The retention rate of the skim on taupair samples is basically zero, so there is
no point producing the skim for these samples.
* The retention rate of the skim on taupair samples is too high (>20%), so the
production system may struggle to handle the jobs.
"""
produces_mdst_by_default = False
"""Special property for combined systematics skims, which produce MDST output instead of
uDST. This property is used by ``b2skim-prod`` to set the ``DataLevel`` parameter in
the ``DataDescription`` block for this skim to ``mdst`` instead of ``udst``.
"""
validation_sample = None
"""
MDST sample to use for validation histograms. Must be a valid location of a
validation dataset (see documentation for `basf2.find_file`).
"""
mc = True
"""
Include Monte Carlo quantities in skim output.
"""
analysisGlobaltag = None
"""
Analysis globaltag.
"""
pidGlobaltag = None
"""
PID globaltag.
"""
@property
@abstractmethod
def __description__(self):
pass
@property
@abstractmethod
def __category__(self):
pass
@property
@abstractmethod
def __authors__(self):
pass
@property
@abstractmethod
def __contact__(self):
pass
@property
def code(self):
"""Eight-digit code assigned to this skim in the registry."""
return Registry.encode_skim_name(self.name)
def __init__(
self,
*,
OutputFileName=None,
additionalDataDescription=None,
udstOutput=True,
validation=False,
mc=True,
analysisGlobaltag=None,
pidGlobaltag=None,
):
"""Initialise the BaseSkim class.
Parameters:
OutputFileName (str): Name to give output uDST files. If none given, then
defaults to eight-number skim code.
additionalDataDescription (dict): additional data description to be added to the output file metadata.
udstOutput (bool): If True, add uDST output to the path.
validation (bool): If True, build lists and write validation histograms
instead of writing uDSTs.
mc (bool): If True, include MC quantities in output.
analysisGlobaltag (str): Analysis globaltag.
pidGlobaltag (str): PID globaltag.
"""
self.name = self.__class__.__name__
self.OutputFileName = OutputFileName
self.additionalDataDescription = additionalDataDescription
self._udstOutput = udstOutput
self._validation = validation
self.mc = mc
self.analysisGlobaltag = analysisGlobaltag
self.pidGlobaltag = pidGlobaltag
if self.NoisyModules is None:
self.NoisyModules = []
[docs]
def load_standard_lists(self, path):
"""
Load any standard lists. This code will be run before any
`BaseSkim.additional_setup` and `BaseSkim.build_lists`.
Note:
This is separated into its own function so that when skims are combined, any
standard lists used by two skims can be loaded just once.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
[docs]
def additional_setup(self, path):
"""
Perform any setup steps necessary before running the skim.
Warning:
Standard particle lists should *not* be loaded in here. This should be done
by overriding the method `BaseSkim.load_standard_lists`. This is crucial for
avoiding loading lists twice when combining skims for production.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
# Abstract method to ensure that it is overridden whenever `BaseSkim` is inherited
[docs]
@abstractmethod
def build_lists(self, path):
"""Create the skim lists to be saved in the output uDST. This function is where
the main skim cuts should be applied. This function should return a list of
particle list names.
Parameters:
path (basf2.Path): Skim path to be processed.
.. versionchanged:: release-06-00-00
Previously, this function was expected to set the attribute
`BaseSkim.SkimLists`. Now this is handled by `BaseSkim`, and this function is
expected to return the list of particle list names.
"""
[docs]
def validation_histograms(self, path):
"""Create validation histograms for the skim.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
# Everything beyond this point can remain as-is when defining a skim
def __call__(self, path):
"""Produce the skim particle lists and write uDST file.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
self._MainPath = path
self.initialise_skim_flag(path)
self.load_standard_lists(path)
self.additional_setup(path)
# At this point, BaseSkim.skim_event_cuts may have been run, so pass
# self._ConditionalPath for the path if it is not None (otherwise just pass the
# regular path)
self.SkimLists = self.build_lists(self._ConditionalPath or path)
self.apply_hlt_hadron_cut_if_required(self._ConditionalPath or path)
self.update_skim_flag(self._ConditionalPath or path)
if self._udstOutput:
self.output_udst(self._ConditionalPath or path)
if self._validation:
if self._method_unchanged("validation_histograms"):
b2.B2FATAL(f"No validation histograms defined for {self} skim.")
self.validation_histograms(self._ConditionalPath or path)
self.set_skim_logging()
@property
def postskim_path(self):
"""
Return the skim path.
* If `BaseSkim.skim_event_cuts` has been run, then the skim lists will only be
created on a conditional path, so subsequent modules should be added to the
conditional path.
* If `BaseSkim.skim_event_cuts` has not been run, then the main analysis path is
returned.
"""
if not self._MainPath:
raise ValueError("Skim has not been added to the path yet!")
return self._ConditionalPath or self._MainPath
SkimLists = []
"""
List of particle lists reconstructed by the skim. This attribute should only be
accessed after running the ``__call__`` method.
"""
_MainPath = None
"""Main analysis path."""
_ConditionalPath = None
"""Conditional path to be set by `BaseSkim.skim_event_cuts` if event-level cuts are applied."""
[docs]
def skim_event_cuts(self, cut, *, path):
"""Apply event-level cuts in a skim-safe way.
Parameters:
cut (str): Event-level cut to be applied.
path (basf2.Path): Skim path to be processed.
Returns:
Path on which the rest of this skim should be processed.
On this path, only events which passed the event-level cut will
be processed further.
.. Tip::
If running this function in `BaseSkim.additional_setup` or
`BaseSkim.build_lists`, redefine the ``path`` to the path returned by
`BaseSkim.skim_event_cuts`, *e.g.*
.. code-block:: python
def build_lists(self, path):
path = self.skim_event_cuts("nTracks>4", path=path)
# rest of skim list building...
.. Note::
The motivation for using this function over `applyEventCuts` is that
`applyEventCuts` completely removes events from processing. If we combine
multiple skims in a single steering file (which is done in production), and
the first has a set of event-level cuts, then all the remaining skims will
never even see those events.
Internally, this function creates a new path, which is only processed for
events passing the event-level cut. To avoid issues around particles not
being available on the main path (leading to noisy error logs), we need to
add the rest of the skim to this path. So this new path is assigned to the
attribute ``BaseSkim._ConditionalPath``, and ``BaseSkim.__call__`` will run
all remaining methods on this path.
"""
if self._ConditionalPath is not None:
b2.B2FATAL(
"BaseSkim.skim_event_cuts cannot be run twice in one skim. "
"Please join your event-level cut strings into a single string."
)
ConditionalPath = b2.Path()
self._ConditionalPath = ConditionalPath
eselect = path.add_module("VariableToReturnValue", variable=f"passesEventCut({cut})")
eselect.if_value('=1', ConditionalPath, b2.AfterConditionPath.CONTINUE)
return ConditionalPath
@property
def TestFiles(self):
"""
Location of test MDST sample. To modify this, set the property
`BaseSkim.TestSampleProcess`, and this function will find an appropriate test
sample from the list in
``/group/belle2/dataprod/MC/SkimTraining/SampleLists/TestFiles.yaml``
If no sample can be found, an empty list is returned.
"""
try:
return [str(get_test_file(process=self.TestSampleProcess))]
except FileNotFoundError:
# Could not find TestFiles.yaml
# (Don't issue a warning, since this will just show up as noise during grid processing)
return []
except ValueError:
b2.B2WARNING(
f"Could not find '{self.TestSampleProcess}' sample in TestFiles.yaml"
)
# Could not find sample in YAML file
return []
@property
def flag(self):
"""
Event-level variable indicating whether an event passes the skim or not. To use
the skim flag without writing uDST output, use the argument ``udstOutput=False``
when instantiating the skim class.
"""
return f"passes_{self}"
[docs]
def initialise_skim_flag(self, path):
"""
Add the module `skim.utils.flags.InitialiseSkimFlag` to the path, which
initialises flag for this skim to zero.
"""
path.add_module(InitialiseSkimFlag(self))
[docs]
def update_skim_flag(self, path):
"""
Add the module `skim.utils.flags.UpdateSkimFlag` to the path, which
updates flag for this skim.
.. Warning::
If a conditional path has been created before this, then this function
*must* run on the conditional path, since the skim lists are not guaranteed
to exist for all events on the main path.
"""
path.add_module(UpdateSkimFlag(self))
def _method_unchanged(self, method):
"""Check if the method of the class is the same as in its parent class, or if it has
been overridden.
Useful for determining if *e.g.* `validation_histograms` has been defined for a
particular skim.
"""
cls = self.__class__
ParentsWithAttr = [parent for parent in cls.__mro__[1:] if hasattr(parent, method)]
if ParentsWithAttr:
# Look for oldest ancestor which as that attribute, to handle inheritance.
# In the case of `validation_histograms`, this will be `BaseSkim`.
OldestParentWithAttr = ParentsWithAttr[-1]
return getattr(cls, method) == getattr(OldestParentWithAttr, method)
else:
return False
def __str__(self):
return self.name
def __name__(self):
return self.name
[docs]
def set_skim_logging(self):
"""Turns the log level to ERROR for selected modules to decrease the total size
of the skim log files. Additional modules can be silenced by setting the attribute
`NoisyModules` for an individual skim.
Parameters:
path (basf2.Path): Skim path to be processed.
.. warning::
This method works by inspecting the modules added to the path, and setting
the log level to ERROR. This method should be called *after* all
skim-related modules are added to the path.
"""
b2.set_log_level(b2.LogLevel.INFO)
NoisyModules = ["ParticleLoader", "ParticleVertexFitter"] + self.NoisyModules
# Set log level of modules on both main path and conditional path
paths = filter(None, (self._MainPath, self._ConditionalPath))
modules = [module for path in paths for module in path.modules()]
for module in modules:
if module.type() in set(NoisyModules):
module.set_log_level(b2.LogLevel.ERROR)
[docs]
def output_udst(self, path):
"""Write the skim particle lists to an output uDST and print a summary of the
skim list statistics.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
# Keep this import here to avoid ROOT hijacking the argument parser
import udst # noqa
# Make a fuss if self.SkimLists is empty
if len(self.SkimLists) == 0:
b2.B2FATAL(
f"No skim list names defined in self.SkimLists for {self} skim!"
)
udst.add_skimmed_udst_output(
skimDecayMode=self.code,
skimParticleLists=self.SkimLists,
outputFile=self.OutputFileName,
dataDescription=self.additionalDataDescription,
mc=self.mc,
path=path,
)
summaryOfLists(self.SkimLists, path=path)
[docs]
def apply_hlt_hadron_cut_if_required(self, path):
"""Apply the ``hlt_hadron`` selection if the property ``ApplyHLTHadronCut`` is True.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
hlt_hadron = "SoftwareTriggerResult(software_trigger_cut&skim&accept_hadron)"
if self.ApplyHLTHadronCut:
for SkimList in self.SkimLists:
applyCuts(SkimList, f"{hlt_hadron}==1", path=path)
[docs]
class CombinedSkim(BaseSkim):
"""Class for creating combined skims which can be run using similar-looking methods
to `BaseSkim` objects.
A steering file which combines skims can be as simple as the following:
.. code-block:: python
import basf2 as b2
import modularAnalysis as ma
from skim.WGs.foo import OneSkim, TwoSkim, RedSkim, BlueSkim
path = b2.Path()
ma.inputMdstList([], path=path)
skims = CombinedSkim(OneSkim(), TwoSkim(), RedSkim(), BlueSkim())
skims(path) # load standard lists, create skim lists, and save to uDST
path.process()
When skims are combined using this class, the `BaseSkim.NoisyModules` lists of each
skim are combined and all silenced.
The heavy-lifting functions `BaseSkim.additional_setup`, `BaseSkim.build_lists` and
`BaseSkim.output_udst` are modified to loop over the corresponding functions of each
invididual skim. The `load_standard_lists` method is also modified to load all
required lists, without accidentally loading a list twice.
Calling an instance of the `CombinedSkim` class will load all the required particle
lists, then run all the setup steps, then the list building functions, and then all
the output steps.
"""
__authors__ = ["Phil Grace"]
__description__ = None
__category__ = "combined"
__contact__ = None
def __init__(
self,
*skims,
NoisyModules=None,
additionalDataDescription=None,
udstOutput=None,
mdstOutput=False,
mdst_kwargs=None,
CombinedSkimName="CombinedSkim",
OutputFileName=None,
mc=None,
analysisGlobaltag=None,
pidGlobaltag=None,
):
"""Initialise the CombinedSkim class.
Parameters:
*skims (BaseSkim): One or more (instantiated) skim objects.
NoisyModules (list(str)): Additional modules to silence.
additionalDataDescription (dict): Overrides corresponding setting of all individual skims.
udstOutput (bool): Overrides corresponding setting of all individual skims.
mdstOutput (bool): Write a single MDST output file containing events which
pass any of the skims in this combined skim.
mdst_kwargs (dict): kwargs to be passed to `mdst.add_mdst_output`. Only used
if ``mdstOutput`` is True.
CombinedSkimName (str): Sets output of ``__str__`` method of this combined skim.
OutputFileName (str): If mdstOutput=True, this option sets the name of the combined output file.
If mdstOutput=False, this option does nothing.
mc (bool): If True, include MC quantities in output.
analysisGlobaltag (str): Analysis globaltag.
pidGlobaltag (str): PID globaltag.
"""
if NoisyModules is None:
NoisyModules = []
# Check that we were passed only BaseSkim objects
if not all([isinstance(skim, BaseSkim) for skim in skims]):
raise NotImplementedError(
"Must pass only `BaseSkim` type objects to `CombinedSkim`."
)
self.Skims = skims
self.name = CombinedSkimName
for skim in self:
skim.NoisyModules += NoisyModules
# empty but needed for functions inherited from baseSkim to work
self.SkimLists = []
if additionalDataDescription is not None:
for skim in self:
skim.additionalDataDescription = additionalDataDescription
self._udstOutput = udstOutput
if udstOutput is not None:
for skim in self:
skim._udstOutput = udstOutput
self.mc = mc
if mc is not None:
for skim in self:
skim.mc = mc
self.analysisGlobaltag = analysisGlobaltag
if analysisGlobaltag is not None:
for skim in self:
skim.analysisGlobaltag = analysisGlobaltag
self.pidGlobaltag = pidGlobaltag
if pidGlobaltag is not None:
for skim in self:
skim.pidGlobaltag = pidGlobaltag
self._mdstOutput = mdstOutput
self.mdst_kwargs = mdst_kwargs or {}
self.mdst_kwargs.update(OutputFileName=OutputFileName)
if mc is not None:
self.mdst_kwargs.update(mc=mc)
self.merge_data_structures()
def __str__(self):
return self.name
def __name__(self):
return self.name
def __call__(self, path):
for skim in self:
skim._MainPath = path
self.initialise_skim_flag(path)
self.load_standard_lists(path)
self.additional_setup(path)
self.build_lists(path)
self.apply_hlt_hadron_cut_if_required(path)
self.update_skim_flag(path)
self._check_duplicate_list_names()
self.output_udst(path)
self.output_mdst_if_any_flag_passes(path=path, **self.mdst_kwargs)
self.set_skim_logging()
def __iter__(self):
yield from self.Skims
[docs]
def load_standard_lists(self, path):
"""Add all required standard list loading to the path.
Note:
To avoid loading standard lists twice, this function creates dummy paths
that are passed through ``load_standard_lists`` for each skim. These dummy
paths are then inspected, and a list of unique module-parameter combinations
is added to the main skim path.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
ModulesAndParams = []
for skim in self:
DummyPath = b2.Path()
skim.load_standard_lists(DummyPath)
# Create a hashable data object to store the information about which
# standard lists have been added to the path.
ModulesAndParams.extend(tuple([
(
module.type(),
tuple(sorted(
(param.name, _hashable_list(param.values) if isinstance(param.values, list) else param.values)
for param in module.available_params()
if param.values != param.default
)),
)
for module in DummyPath.modules()
]))
# Take this data structure and convert it to a dict. This removes any duplicate entries.
ModulesAndParams = dict.fromkeys(ModulesAndParams)
# Add the (now unique) module+param combinations to the main path
for module, params in ModulesAndParams:
path.add_module(module, **dict(params))
[docs]
def additional_setup(self, path):
"""Run the `BaseSkim.additional_setup` function of each skim.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
for skim in self:
skim.additional_setup(path)
[docs]
def build_lists(self, path):
"""Run the `BaseSkim.build_lists` function of each skim.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
for skim in self:
skim.SkimLists = skim.build_lists(skim._ConditionalPath or path)
[docs]
def output_udst(self, path):
"""Run the `BaseSkim.output_udst` function of each skim.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
for skim in self:
if skim._udstOutput:
skim.output_udst(skim._ConditionalPath or path)
[docs]
def output_mdst_if_any_flag_passes(self, *, path, **kwargs):
"""
Add MDST output to the path if the event passes any of the skim flags.
EventExtraInfo is included in the MDST output so that the flags are available in
the output.
The ``CombinedSkimName`` parameter in the `CombinedSkim` initialisation is used
for the output filename if ``filename`` is not included in kwargs.
Parameters:
path (basf2.Path): Skim path to be processed.
**kwargs: Passed on to `mdst.add_mdst_output`.
"""
from mdst import add_mdst_output
if not self._mdstOutput:
return
sum_flags = " + ".join(f"eventExtraInfo({f})" for f in self.flags)
variable = f"formula({sum_flags})"
passes_flag_path = b2.Path()
passes_flag = path.add_module("VariableToReturnValue", variable=variable)
passes_flag.if_value(">0", passes_flag_path, b2.AfterConditionPath.CONTINUE)
filename = kwargs.get("filename", kwargs.get("OutputFileName", self.code))
if filename is None:
filename = self.code
if not filename.endswith(".mdst.root"):
filename += ".mdst.root"
kwargs["filename"] = filename
if "OutputFileName" in kwargs.keys():
del kwargs["OutputFileName"]
kwargs.setdefault("dataDescription", {})
# If the combinedSkim is not in the registry getting the code will throw a LookupError.
# There is no requirement that a combinedSkim with single MDST output is
# registered so set the skimDecayMode to ``None`` if no code is defined.
try:
skim_code = self.code
except LookupError:
skim_code = None
kwargs["dataDescription"].setdefault("skimDecayMode", skim_code)
try:
kwargs["additionalBranches"] += ["EventExtraInfo"]
except KeyError:
kwargs["additionalBranches"] = ["EventExtraInfo"]
add_mdst_output(path=passes_flag_path, **kwargs)
[docs]
def apply_hlt_hadron_cut_if_required(self, path):
"""Run the `BaseSkim.apply_hlt_hadron_cut_if_required` function for each skim.
Parameters:
path (basf2.Path): Skim path to be processed.
"""
for skim in self:
skim.apply_hlt_hadron_cut_if_required(skim._ConditionalPath or path)
[docs]
def set_skim_logging(self):
"""Run `BaseSkim.set_skim_logging` for each skim."""
for skim in self:
skim.set_skim_logging()
@property
def TestFiles(self):
return list({f for skim in self for f in skim.TestFiles})
@property
def flags(self):
"""
List of flags for each skim in combined skim.
"""
return [skim.flag for skim in self]
@property
def flag(self):
"""
Event-level variable indicating whether an event passes the combinedSkim or not.
"""
return f"passes_{self}"
[docs]
def initialise_skim_flag(self, path):
"""
Add the module `skim.utils.flags.InitialiseSkimFlag` to the path, to
initialise flags for each skim.
"""
path.add_module(InitialiseSkimFlag(*self))
[docs]
def update_skim_flag(self, path):
"""
Add the module `skim.utils.flags.UpdateSkimFlag` to the conditional path
of each skims.
"""
for skim in self:
skim.postskim_path.add_module(UpdateSkimFlag(skim))
@property
def produce_on_tau_samples(self):
"""
Corresponding value of this attribute for each individual skim.
A warning is issued if the individual skims in combined skim contain a mix of
True and False for this property.
"""
produce_on_tau = [skim.produce_on_tau_samples for skim in self]
if all(produce_on_tau):
return True
elif all(not TauBool for TauBool in produce_on_tau):
return False
else:
warnings.warn(
(
"The individual skims in the combined skim contain a mix of True and "
"False for the attribute `produce_on_tau_samples`.\n The default in "
"this case is to allow the combined skim to be produced on tau samples.\n"
" Skims included in the problematic combined skim: "
f"{', '.join(skim.name for skim in self)}"
),
RuntimeWarning,
)
return True
[docs]
def merge_data_structures(self):
"""Read the values of `BaseSkim.MergeDataStructures` and merge data structures
accordingly.
For example, if ``MergeDataStructures`` has the value ``{"FEIChannelArgs":
_merge_boolean_dicts.__func__}``, then ``_merge_boolean_dicts`` is run on all
input skims with the attribute ``FEIChannelArgs``, and the value of
``FEIChannelArgs`` for that skim is set to the result.
In the FEI skims, this is used to merge configs which are passed to a cached
function, thus allowing us to apply the FEI once with all the required particles
available.
"""
for iSkim, skim in enumerate(self.Skims):
for attribute, MergingFunction in skim.MergeDataStructures.items():
SkimsWithAttribute = [skim for skim in self if hasattr(skim, attribute)]
setattr(
self.Skims[iSkim],
attribute,
MergingFunction(*[getattr(skim, attribute) for skim in SkimsWithAttribute])
)
def _check_duplicate_list_names(self):
"""Check for duplicate particle list names.
.. Note::
Skims cannot be relied on to define their particle list names in advance, so
this function can only be run after `build_lists` is run.
"""
ParticleListLists = [skim.SkimLists for skim in self]
ParticleLists = [lst for L in ParticleListLists for lst in L]
DuplicatedParticleLists = {
ParticleList
for ParticleList in ParticleLists
if ParticleLists.count(ParticleList) > 1
}
if DuplicatedParticleLists:
raise ValueError(
f"Non-unique output particle list names in combined skim! "
f"{', '.join(DuplicatedParticleLists)}"
)