diff --git a/.gitignore b/.gitignore index 82ed05d..2e2297d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ samples/histograms/* bin/* build/* + +*.pyc diff --git a/configs/examples/minimal_plotter_config.py b/configs/examples/minimal_plotter_config.py index cb70c69..765c383 100644 --- a/configs/examples/minimal_plotter_config.py +++ b/configs/examples/minimal_plotter_config.py @@ -3,6 +3,15 @@ from Legend import Legend from Histogram import Histogram from HistogramNormalizer import NormalizationType +from CmsLabelsManager import CmsLabel + +year = "2018" +show_cms_labels = True +label_outside_axes = True + +# Allowed labels: paper, paper_sim, paper_supplementary, paper_sim_supplementary, pas, pas_sim, pas_supplementary, +# pas_sim_supplementary, thesis, thesis_sim, private_data_sim, private_data, private_sim. +cms_label = CmsLabel.paper_sim_supplementary samples = ( Sample( @@ -21,8 +30,8 @@ histograms = ( # name title logx logy norm_type rebin xmin xmax ymin ymax xlabel ylabel - Histogram("Muon_pt" , "Muon p_{T}", False, True, NormalizationType.to_lumi, 5, 0 , 150, 1, 1e3 , "p_{T} [GeV]", "# events (2018)"), - Histogram("Muon_eta", "Muon #eta", False, False, NormalizationType.to_lumi, 5, -2.4, 2.4, 0, 70 , "#eta" , "# events (2018)"), + Histogram("Muon_pt" , "Muon p_{T}", False, True, NormalizationType.to_lumi, 5, None , None, None, None , "p_{T} [GeV]", "# events (2018)"), + Histogram("Muon_eta", "Muon #eta", False, False, NormalizationType.to_lumi, 5, None, None, None, None , "#eta" , "# events (2018)"), ) luminosity = 63670. # pb^-1 (2018) diff --git a/pylibs/plotting/CmsLabelsManager.py b/pylibs/plotting/CmsLabelsManager.py index 5c96028..2d243d0 100644 --- a/pylibs/plotting/CmsLabelsManager.py +++ b/pylibs/plotting/CmsLabelsManager.py @@ -1,4 +1,23 @@ import ROOT +from enum import Enum + + +class CmsLabel(Enum): + """Allowed CMS plot labels from the CMS plotting guidelines.""" + + paper = ("CMS", None) + paper_sim = ("CMS", "Simulation") + paper_supplementary = ("CMS", "Supplementary") + paper_sim_supplementary = ("CMS", "Simulation Supplementary") + pas = ("CMS", "Preliminary") + pas_sim = ("CMS", "Simulation Preliminary") + pas_supplementary = ("CMS", "Preliminary") + pas_sim_supplementary = ("CMS", "Simulation Preliminary") + thesis = ("CMS", "Work in progress") + thesis_sim = ("CMS", "Simulation Work in progress") + private_data_sim = (None, "Private work (CMS data/simulation)") + private_data = (None, "Private work (CMS data)") + private_sim = (None, "Private work (CMS simulation)") class CmsLabelsManager: @@ -9,16 +28,16 @@ def __init__(self, config): if hasattr(self.config, "show_cms_labels"): self.show_labels = self.config.show_cms_labels - self.cmsText = "CMS" - if hasattr(self.config, "label_text"): - self.cmsText = self.config.label_text + cms_label = getattr(self.config, "cms_label", CmsLabel.paper) + if not isinstance(cms_label, CmsLabel): + raise TypeError("cms_label must be a CmsLabel enum value") + if hasattr(self.config, "label_text") or hasattr(self.config, "extraText"): + raise ValueError("Use cms_label with a CmsLabel enum value instead of free text") + self.cmsText, self.extraText = cms_label.value - self.cmsTextFont = 61 + self.cmsTextFont = 63 - self.extraText = None - if hasattr(self.config, "extraText"): - self.extraText = self.config.extraText - self.extraTextFont = 52 + self.extraTextFont = 53 self.lumiTextSize = 0.6 @@ -27,17 +46,15 @@ def __init__(self, config): else: self.lumiTextOffset = -0. - self.cmsTextSize = 0.75 + self.labelTextSize = 20 self.cmsTextOffset = 0.1 if config.show_ratio_plots: - self.relPosX = 0.045 - self.relPosY = 0.035 - self.relExtraDY = 1.2 + self.relPosX = 0.055 + self.relPosY = 0.04 else: - self.relPosX = 0.045 - self.relPosY = 0.070 - self.relExtraDY = 1.2 + self.relPosX = 0.055 + self.relPosY = 0.04 if hasattr(self.config, "label_x"): self.relPosX = self.config.label_x @@ -45,7 +62,7 @@ def __init__(self, config): if hasattr(self.config, "label_y"): self.relPosY = self.config.label_y - self.extraOverCmsTextSize = 0.76 + self.label_outside_axes = getattr(self.config, "label_outside_axes", False) if hasattr(self.config, "lumi_unit"): lumi_unit = self.config.lumi_unit @@ -95,6 +112,10 @@ def __setVariables(self, pad): def __drawLumiText(self): lumiText = self.lumi + self.collision_energy + if self.label_outside_axes: + _, label_pos_y = self.__label_position() + else: + label_pos_y = 1-self.top + 0.015 latex = ROOT.TLatex() latex.SetNDC() @@ -103,8 +124,9 @@ def __drawLumiText(self): latex.SetTextFont(43) latex.SetTextAlign(31) - latex.SetTextSize(20) - latex.DrawLatex(1-self.right, 1-self.top + self.lumiTextOffset * self.top, lumiText) + latex.SetTextSize(self.labelTextSize) + right_boundary = 1-self.right + latex.DrawLatex(right_boundary, label_pos_y + self.lumiTextOffset * self.top, lumiText) def __drawLogo(self): posX_ = self.left + 0.045 * \ @@ -122,54 +144,63 @@ def __drawLogo(self): pad_logo.Modified() def __drawCmsText(self): - - posX_ = self.left + self.relPosX*(1-self.left-self.right) - posY_ = 1-self.top - self.relPosY*(1-self.top-self.bottom) + posX_, posY_ = self.__label_position() latex = ROOT.TLatex() + latex.SetNDC() + if self.cmsText is None: + return + latex.SetTextFont(self.cmsTextFont) - latex.SetTextSize(self.cmsTextSize*self.top) - latex.SetTextAlign(13) + latex.SetTextSize(self.labelTextSize) + latex.SetTextAlign(11 if self.label_outside_axes else 13) latex.DrawLatex(posX_, posY_, self.cmsText) def __drawExtraCmsText(self): if self.extraText is None: return - posX_ = self.left + self.relPosX*(1-self.left-self.right) - posY_ = 1-self.top - self.relPosY*(1-self.top-self.bottom) + posX_, posY_ = self.__label_position() + if self.label_outside_axes and self.cmsText is not None: + posX_ += 55.0/self.width latex = ROOT.TLatex() + latex.SetNDC() latex.SetTextFont(self.extraTextFont) - latex.SetTextAlign(13) - extraTextSize = self.extraOverCmsTextSize * self.cmsTextSize - latex.SetTextSize(extraTextSize*self.top) - latex.DrawLatex(posX_, posY_ - self.relExtraDY * - self.cmsTextSize*self.top, self.extraText) + latex.SetTextAlign(11 if self.label_outside_axes else 13) + latex.SetTextSize(self.labelTextSize) + extra_pos_y = posY_ + if not self.label_outside_axes: + extra_pos_y -= (self.labelTextSize + 6.0)/self.height + latex.DrawLatex(posX_, extra_pos_y, self.extraText) + + def __label_position(self): + if self.label_outside_axes: + return self.left, 1 - self.top + 0.015 + return (self.left + self.relPosX*(1-self.left-self.right), + 1-self.top - self.relPosY*(1-self.top-self.bottom)) def drawLabels2D(self, canvas): - latex = ROOT.TLatex() - latex.SetTextFont(self.cmsTextFont) - latex.SetTextSize(self.cmsTextSize*self.top) - - latex.SetTextAlign(13) - latex.SetNDC() - latex.DrawLatex(0.1, 0.99, self.cmsText) + if self.cmsText is not None: + latex = ROOT.TLatex() + latex.SetTextFont(self.cmsTextFont) + latex.SetTextSize(self.labelTextSize) + latex.SetTextAlign(13) + latex.SetNDC() + latex.DrawLatex(0.1, 0.99, self.cmsText) canvas.Update() - cms_width = latex.GetTextSize() - print(f"cms width: {cms_width}") + cms_width = 55.0/canvas.GetWw() if self.extraText is not None: latex = ROOT.TLatex() latex.SetTextFont(self.extraTextFont) latex.SetTextAlign(13) - extraTextSize = self.extraOverCmsTextSize * self.cmsTextSize - latex.SetTextSize(extraTextSize*self.top) + latex.SetTextSize(self.labelTextSize) latex.SetNDC() - posX_ = 0.1 + 2*cms_width + 0.01 - posY_ = 0.99 - 0.01 + posX_ = 0.1 if self.cmsText is None else 0.1 + cms_width + posY_ = 0.99 if self.cmsText is None else 0.99 - 0.01 latex.DrawLatex(posX_, posY_, self.extraText) @@ -180,5 +211,5 @@ def drawLabels2D(self, canvas): latex.SetTextColor(ROOT.kBlack) latex.SetTextFont(43) latex.SetTextAlign(31) - latex.SetTextSize(20) + latex.SetTextSize(self.labelTextSize) latex.DrawLatex(1-self.right, 1-self.top + 2*self.lumiTextOffset * self.top + 0.01, lumiText) diff --git a/pylibs/plotting/Histogram.py b/pylibs/plotting/Histogram.py index 0b17f3a..269441e 100644 --- a/pylibs/plotting/Histogram.py +++ b/pylibs/plotting/Histogram.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from copy import deepcopy from array import array +from typing import Optional import ROOT from Sample import SampleType @@ -16,10 +17,10 @@ class Histogram: log_y: bool = False norm_type: int = NormalizationType.to_lumi rebin: int = 1 - x_min: float = 0.0 - x_max: float = 0.0 - y_min: float = 0.0 - y_max: float = 0.0 + x_min: Optional[float] = None + x_max: Optional[float] = None + y_min: Optional[float] = None + y_max: Optional[float] = None x_label: str = "" y_label: str = "" suffix: str = "" @@ -63,11 +64,13 @@ def load(self, input_file): if not self.isGood(): return - if self.x_max > 0 or self.x_min > 0: + if self.x_min is not None or self.x_max is not None: original_bins = [self.hist.GetBinLowEdge(i) for i in range(1, self.hist.GetNbinsX() + 2)] - new_bin_edges = [x for x in original_bins if self.x_min <= x <= self.x_max] - if self.x_max not in new_bin_edges: - new_bin_edges.append(self.x_max) + x_min = self.x_min if self.x_min is not None else original_bins[0] + x_max = self.x_max if self.x_max is not None else original_bins[-1] + new_bin_edges = [x for x in original_bins if x_min <= x <= x_max] + if x_max not in new_bin_edges: + new_bin_edges.append(x_max) new_n_bins = len(new_bin_edges) - 1 new_histogram = ROOT.TH1F(f"{self.hist.GetName()}_{self.rand.Integer(1000000)}", @@ -128,12 +131,12 @@ class Histogram2D: norm_type: int = NormalizationType.to_lumi x_rebin: int = 1 y_rebin: int = 1 - x_min: float = 0.0 - x_max: float = 0.0 - y_min: float = 0.0 - y_max: float = 0.0 - z_min: float = 0.0 - z_max: float = 0.0 + x_min: Optional[float] = None + x_max: Optional[float] = None + y_min: Optional[float] = None + y_max: Optional[float] = None + z_min: Optional[float] = None + z_max: Optional[float] = None x_label: str = "" y_label: str = "" z_label: str = "" diff --git a/pylibs/plotting/HistogramPlotter.py b/pylibs/plotting/HistogramPlotter.py index 235f4b0..574e809 100644 --- a/pylibs/plotting/HistogramPlotter.py +++ b/pylibs/plotting/HistogramPlotter.py @@ -321,8 +321,17 @@ def __drawLineAtOne(self, canvas, hist): if not self.show_ratios: return + ratio_stack = self.__getRatioStack(hist) + if ratio_stack is None or not hasattr(ratio_stack, "GetHistogram"): + return + ratio_histogram = ratio_stack.GetHistogram() + if ratio_histogram is None: + return + x_min = hist.x_min if hist.x_min is not None else ratio_histogram.GetXaxis().GetXmin() + x_max = hist.x_max if hist.x_max is not None else ratio_histogram.GetXaxis().GetXmax() + global line - line = ROOT.TLine(hist.x_min, 1, hist.x_max, 1) + line = ROOT.TLine(x_min, 1, x_max, 1) line.SetLineColor(ROOT.kBlack) line.SetLineStyle(ROOT.kDashed) @@ -402,9 +411,26 @@ def __drawHists(self, canvas, hist): stack = self.stacks[sample_type][hist.getName()] if stack.GetNhists() > 0: stack.Draw(options) - self.styler.setupFigure(stack, hist) firstPlotted = True + # Apply automatic limits only after every sample has been drawn. A + # THStack's axis is established by its first draw, while subsequent + # stacks (signals/data) can contain larger contributions. + plotted_histograms = [] + for sample_type in SampleType: + stack = self.stacks[sample_type][hist.getName()] + if stack.GetNhists() == 0: + continue + stack_histograms = stack.GetStack() + if stack_histograms and stack_histograms.GetSize() > 0: + plotted_histograms.append(stack_histograms.Last()) + + first_stack = next((self.stacks[sample_type][hist.getName()] + for sample_type in SampleType + if self.stacks[sample_type][hist.getName()].GetNhists() > 0), None) + if first_stack is not None: + self.styler.setupFigure(first_stack, hist, source_histograms=plotted_histograms) + def __drawRatioHists(self, canvas, hist): canvas.cd(1) @@ -442,7 +468,7 @@ def drawStacks(self): self.__drawHists(canvas, hist) self.__drawUncertainties(canvas, hist) self.__drawLegends(canvas, hist) - self.cmsLabelsManager.drawLabels(canvas) + self.cmsLabelsManager.drawLabels(canvas.GetPad(1)) # make sure plot border is on top of everything else canvas.GetPad(1).GetFrame().SetLineWidth(2) @@ -500,7 +526,7 @@ def drawRatioStacks(self): self.__drawRatioHists(canvas, hist_nom) self.__drawLegends(canvas, hist_nom) - self.cmsLabelsManager.drawLabels(canvas) + self.cmsLabelsManager.drawLabels(canvas.GetPad(1)) canvas.Update() diff --git a/pylibs/plotting/Legend.py b/pylibs/plotting/Legend.py index 1dbb897..8ae1b62 100644 --- a/pylibs/plotting/Legend.py +++ b/pylibs/plotting/Legend.py @@ -11,6 +11,7 @@ class Legend: options: str = "" title: str = "" text_size: float = 20 + fill_color: int = None def getRootLegend(self): legend = TLegend(self.x1, self.y1, self.x2, self.y2) @@ -19,8 +20,12 @@ def getRootLegend(self): def __setupLegend(self, legend): legend.SetBorderSize(0) - # legend.SetFillColor(0) - # legend.SetFillStyle(0) + # Keep the plot visible behind the legend by default. A fill color + # opts back into a solid legend background for users who need one. + legend.SetFillStyle(0) + if self.fill_color is not None: + legend.SetFillColor(self.fill_color) + legend.SetFillStyle(1001) legend.SetTextFont(43) legend.SetTextSize(self.text_size) diff --git a/pylibs/plotting/Styler.py b/pylibs/plotting/Styler.py index f078823..51ff3c8 100644 --- a/pylibs/plotting/Styler.py +++ b/pylibs/plotting/Styler.py @@ -126,7 +126,7 @@ def __setStyle(self): gStyle.SetPaperSize(20., 20.) - def setupFigure(self, plot, hist, is_ratio=False): + def setupFigure(self, plot, hist, is_ratio=False, source_histograms=None): if plot is None or type(plot) is TObject: return @@ -134,14 +134,16 @@ def setupFigure(self, plot, hist, is_ratio=False): plot.SetMinimum(self.config.ratio_limits[0]) plot.SetMaximum(self.config.ratio_limits[1]) else: - if (hist.y_min > 0) or (not hist.log_y and hist.y_min == 0): + self.__setAutomaticLimits(plot, hist, source_histograms) + if hist.y_min is not None and ((hist.y_min > 0) or (not hist.log_y and hist.y_min == 0)): plot.SetMinimum(hist.y_min) - if (hist.y_max > 0): + if hist.y_max is not None and hist.y_max > 0: plot.SetMaximum(hist.y_max) try: plot.SetTitle("" if is_ratio else hist.title) - plot.GetXaxis().SetLimits(hist.x_min, hist.x_max) + if hist.x_min is not None and hist.x_max is not None: + plot.GetXaxis().SetLimits(hist.x_min, hist.x_max) plot.GetXaxis().SetTitle(hist.x_label) @@ -163,6 +165,53 @@ def setupFigure(self, plot, hist, is_ratio=False): warn("Couldn't set axes limits") return + def __setAutomaticLimits(self, plot, hist, source_histograms=None): + """Set missing bounds from all plotted contributions, with a small margin.""" + if not hasattr(plot, "GetHistogram"): + return + source_histogram = plot.GetHistogram() + if source_histogram is None: + return + + source_histograms = source_histograms or [source_histogram] + + x_min = min(h.GetXaxis().GetXmin() for h in source_histograms) + x_max = max(h.GetXaxis().GetXmax() for h in source_histograms) + if hist.x_min is None or hist.x_max is None: + if x_min <= 0 or x_max <= 0: + padding = 0.05 * (x_max - x_min) + automatic_x_min = x_min - padding + automatic_x_max = x_max + padding + else: + automatic_x_min = 0.7 * x_min + automatic_x_max = 1.3 * x_max + plot.GetXaxis().SetLimits( + hist.x_min if hist.x_min is not None else automatic_x_min, + hist.x_max if hist.x_max is not None else automatic_x_max) + + if hist.y_min is None or hist.y_max is None: + values = [] + for source in source_histograms: + values.extend(source.GetBinContent(i) + for i in range(1, source.GetNbinsX() + 1)) + positive_values = [value for value in values if value > 0] + maximum = max(positive_values) if positive_values else 1.0 + if hist.log_y: + minimum = min(positive_values) if positive_values else maximum / 1000.0 + automatic_y_min = 0.7 * minimum + else: + automatic_y_min = min(values) if values else 0.0 + automatic_y_min = min(0.0, automatic_y_min) + automatic_y_max = 1.3 * maximum + minimum = hist.y_min + if minimum is None or (hist.log_y and minimum <= 0): + minimum = automatic_y_min + maximum = hist.y_max + if maximum is None or maximum <= 0: + maximum = automatic_y_max + plot.SetMinimum(minimum) + plot.SetMaximum(maximum) + def setupFigure2D(self, plot, hist): if plot is None or type(plot) is TObject: return @@ -174,19 +223,15 @@ def setupFigure2D(self, plot, hist): label_size = self.labelFontSize / float(pad.GetWh()) label_font = 42 - if (hist.y_min > 0): - plot.SetMinimum(hist.y_min) - if (hist.y_max > 0): - plot.SetMaximum(hist.y_max) - - if (hist.z_min > 0): + if hist.z_min is not None and (hist.z_min > 0): plot.SetMinimum(hist.z_min) - if (hist.z_max > 0): + if hist.z_max is not None and (hist.z_max > 0): plot.SetMaximum(hist.z_max) try: plot.SetTitle(hist.title) - plot.GetXaxis().SetRangeUser(hist.x_min, hist.x_max) + if hist.x_min is not None and hist.x_max is not None: + plot.GetXaxis().SetRangeUser(hist.x_min, hist.x_max) plot.GetXaxis().SetTitle(hist.x_label) plot.GetXaxis().SetTitleFont(label_font) plot.GetXaxis().SetTitleSize(label_size) @@ -194,7 +239,8 @@ def setupFigure2D(self, plot, hist): plot.GetXaxis().SetLabelFont(label_font) plot.GetXaxis().SetLabelSize(label_size) - plot.GetYaxis().SetRangeUser(hist.y_min, hist.y_max) + if hist.y_min is not None and hist.y_max is not None: + plot.GetYaxis().SetRangeUser(hist.y_min, hist.y_max) plot.GetYaxis().SetTitle(hist.y_label) plot.GetYaxis().SetTitleFont(label_font) plot.GetYaxis().SetTitleSize(label_size)