Belle II Software development
validationplotuple.py
1#!/usr/bin/env python3
2
3
10
11
12# std
13import os.path
14import math
15import json
16from typing import List, Optional
17
18# 3rd
19import ROOT
20
21# ours
22import metaoptions
23import validationcomparison
24import validationpath
25from validationfunctions import strip_ext, index_from_revision, get_style
26import json_objects
27from validationrootobject import RootObject
28
29
30# todo: [Ref, low prio, medium work] Refactor into class with uniform interface
31# and subclasses implementing actual functionality for Plot/Tuple etc.
32# /klieret
33class Plotuple:
34
35 """!
36 A Plotuple is either a Plot or an N-Tuple
37
38 @var work_folder: the work folder containing the results and plots
39 @var _root_objects: A list of Root-objects which belong
40 together (i.e. should be drawn into one histogram or one table)
41 @var _revisions: The list of revisions
42 @var warnings: A list of warnings that occurred while creating the
43 plots/tables for this Plotuple object
44 @var _reference: The reference RootObject for this Plotuple
45 @var _elements: The elements (RootObject of different revisions) for this
46 Plotuple
47 @var _newest: The newest element in elements
48 @var key: The key of the object within the corresponding ROOT file
49 @var type: The type of the elements (TH1, TH2, TNtuple)
50 @var _description: The description of this Plotuple object
51 @var _check: Hint how the Plotuple object should look like
52 @var contact: The contact person for this Plotuple object
53 @var package: The package to which this Plotuple object belongs to
54 @var rootfile: The rootfile to which the Plotuple object belongs to
55 @var _file: The file, in which the histogram or the HMTL-table (for
56 n-tuples) are stored (without the file extension!)
57 """
58
59 def __init__(
60 self,
61 root_objects: List[RootObject],
62 revisions: List[str],
63 work_folder: str,
64 ):
65 """!
66 The default constructor for a Plotuple-object.
67 @param root_objects: A list of Root-objects which belong
68 together (i.e. should be drawn into one histogram or one table)
69 @param revisions: The list of revisions (Duh!)
70 """
71
72 # the work folder containing the results and plots
73 self._work_folder = work_folder
74
75 # The list of Root objects in this Plotuple-object
76 self._root_objects = root_objects
77
78 # The list of revisions
79 self._revisions = revisions
80
81 # A list of all problems that occurred with this Plotuple,
82 # e.g. missing reference object, missing meta-information...
83 self.warnings: List[str] = []
84
85 # Find the reference element. If we can't find one, set it to 'None'
86 # The reference-object for this Plotuple object
87 self._reference: Optional[RootObject] = None
88 for root_object in self._root_objects:
89 if root_object.is_reference:
90 self._reference = root_object
91 break
92
93 # If we couldn't find a reference element, add that to warnings
94 if self._reference is None:
95 self.warnings = ["No reference object"]
96
97 # All elements of the Plotuple that are not the reference-object
98 # Get the elements, i.e. all RootObjects except for the
99 # reference object. May be either histograms or n-tuples.
100 # Note that the reference doesn't have a date set (and if we only plot
101 # the reference, then is_reference is probably not set), so we have
102 # to be careful of how to sort
103 self._elements = [ro for ro in root_objects if not ro.is_reference]
104 self._elements.sort(
105 key=lambda ro: ro.date if ro.date else 0, reverse=True
106 )
107
108 # The newest element, i.e. the element belonging the revision
109 # whose data were created most recently.
110 # Should always be self.element[0], except if there is only a
111 # reference object
112 if self._elements:
113 self._newest = self._elements[0]
114 else:
115 self._newest = self._reference
116
117 # All available meta-information about the plotuple object:
118
119 # The key (more precisely: the name of the key) that all elements of
120 # this Plotuple object share
121 self.key = self._newest.key
122
123 # The type of the elements in this Plotuple object
124 self.type = self._newest.type
125
126 if self.type == "TNamed":
127 # Sometimes, we use TNamed to encode extra information about the
128 # ROOT file. In order to avoid that this will be plotted, we
129 # catch it here and assign it the type 'Meta'
130 meta_fields = ["description"]
131 if self._newest.object.GetName().lower().strip() in meta_fields:
132 self.type = "meta"
133
134 # The description of the histogram/n-tuple which this Plotuple object
135 # will yield
136 self._description = self._newest.description
137
138 # The 'Check for ...'-guideline for the histogram/n-tuple which this
139 # Plotuple object will yield
140 self._check = self._newest.check
141
142 # A contact person for the histogram/n-tuple which this Plotuple object
143 # will yield
144 self._contact = self._newest.contact
145
146 # MetaOptionParser for the meta-options for this Plotuple object
147 self._mop = metaoptions.MetaOptionParser(self._newest.metaoptions)
148
149 # The package to which the elements in this Plotuple object belong
150 self.package = self._newest.package
151
152 # The root file to which the elements in this Plotuple object belong
153 self.rootfile = self._newest.rootfile
154
155 # The result of the Chi^2-Test. By default, there is no such result.
156 # If the Chi^2-Test has been performed, this variable holds between
157 # which objects it has been performed.
158 self._comparison_result_long = "n/a"
159
160 self.comparison_result = "not_compared"
161
162 # The json file, in which the ntuple information is stored
163 self._file: Optional[str] = None
164
165 self._html_content: Optional[str] = None
166
167
169 self._width: Optional[int] = None
170
171
173 self._height: Optional[int] = None
174
175 # Deal with incomplete information
176 if self._description == "" or self._description is None:
177 self._description = "n/a"
178 self.warnings.append("No description")
179 if self._check == "" or self._check is None:
180 self._check = "n/a"
181 self.warnings.append("No Check")
182 if self._contact == "" or self._contact is None:
183 self._contact = "n/a"
184 self.warnings.append("No Contact Person")
185
186
189 self._plot_folder = os.path.join(
191 work_folder, tags=revisions
192 ),
193 self.package,
194 )
195 os.makedirs(self._plot_folder, exist_ok=True)
196
197 def has_reference(self):
198 """!
199 @return True if a reference file is found for this plotuple
200 """
201 return self._reference is not None
202
203 def create_plotuple(self):
204 """!
205 Creates the histogram/table/image that belongs to this Plotuble-object.
206 """
207
208 if self.type == "TH1" or self.type == "TEfficiency":
209 self.create_histogram_plot("1D")
210 elif self.type == "TGraph":
211 self.create_graph_plot()
212 elif self.type == "TH2":
213 self.create_histogram_plot("2D")
214 # used to store HTML user content
215 elif self.type == "TNamed":
216 self.create_html_content()
217 elif self.type == "TNtuple":
218 self.create_ntuple_table_json()
219 elif self.type == "meta":
220 pass
221 else:
222 raise ValueError(
223 "Tried to create histogram/n-tuple, but received" "invalid type"
224 )
225
226 def is_expert(self):
227 """!
228 @return Returns true if this plotuple has the expert option
229 """
230 return not self._mop.has_option("shifter")
231
232 def perform_comparison(self):
233 """!
234 Takes the reference (self.reference.object) and the newest revision
235 (self.newest.object) and a canvas. Performs a comparison of the
236 two objects.
237 @return: None
238 """
239
240 tester = validationcomparison.get_comparison(
241 self._reference.object, self._newest.object, self._mop
242 )
243
244 self._comparison_result_long = tester.comparison_result_long.format(
245 revision1=self._reference.revision, revision2=self._newest.revision
246 )
247 self.comparison_result = tester.comparison_result
248
249 def _set_background(self, canvas):
250
251 # kRed #FF0000 Red
252 # kRed - 9 #FF9999 Sweet pink
253
254 # kOrange + 1 #FF9832 Sun
255 # kOrange - 9 #FFCC9A Manhattan
256
257 # kGreen - 3 #33CC33 Lime green
258 # kGreen - 10 #CCFFCC Chinook
259
260 # kAzure #0032FE Medium blue
261 # kAzure - 2 #3265FE Medium slate blue
262 # kAzure - 9 #98CBFF Jordy blue
263
264 colors = {
265 "error": ROOT.kRed,
266 "warning": ROOT.kOrange + 1,
267 "equal": ROOT.kGreen - 3,
268 "not_compared": ROOT.kAzure - 2,
269 }
270 colors_expert = {
271 "error": ROOT.kRed - 9,
272 "warning": ROOT.kOrange - 9,
273 "equal": ROOT.kGreen - 10,
274 "not_compared": ROOT.kAzure - 9,
275 }
276
277 if self.is_expert():
278 color = colors_expert[self.comparison_result]
279 else:
280 color = colors[self.comparison_result]
281
282 canvas.SetFillColor(color)
283 canvas.GetFrame().SetFillColor(ROOT.kWhite)
284
285 def _draw_ref(self, canvas):
286 """!
287 Takes the reference RootObject (self.reference.object)
288 and a (sub)canvas and plots it with the correct line-style etc.
289 @param canvas: Reference to the canvas on which we will draw the
290 reference object.
291 @return. None
292 """
293 self._remove_stats_tf1(self._reference.object)
294
295 # Line is thick and black
296 self._reference.object.SetLineColor(ROOT.kBlack)
297 self._reference.object.SetLineWidth(2)
298 self._reference.object.SetLineStyle(1)
299
300 # Area under the curve is solid gray
301 self._reference.object.SetFillColor(ROOT.kGray)
302 self._reference.object.SetFillStyle(1001)
303
304 # Draw the reference on the canvas
305 self._draw_root_object(
306 self.type,
307 self._reference.object,
308 self._reference.object.GetOption(),
309 )
310 canvas.Update()
311 canvas.GetFrame().SetFillColor(ROOT.kWhite)
312
313 @staticmethod
314 def _remove_stats_tf1(obj):
315 # removed TF1s which might have been added by validation scripts
316 # in tracking/scripts/tracking/validation/plot.py:1597
317 tf1 = obj.FindObject("FitAndStats")
318 if tf1:
319 function_list = obj.GetListOfFunctions()
320 function_list.Remove(tf1)
321
322 def get_png_filename(self):
323 return f"{strip_ext(self.rootfile)}_{self.key}.png"
324
325 def get_pdf_filename(self):
326 return f"{strip_ext(self.rootfile)}_{self.key}.pdf"
327
328 @staticmethod
329 def _draw_root_object(typ, obj, options):
330 """
331 Special handling of the ROOT Draw calls, as some
332 ROOT objects have a slightly different flavour.
333 """
334
335 if typ == "TEfficiency" or typ == "TGraph":
336 # TEff does not provide DrawCopy
337 obj.Draw(options)
338 else:
339 obj.DrawCopy(options)
340
341 def create_histogram_plot(self, mode):
342 """!
343 Plots all histogram-objects in this Plotuple together in one histogram,
344 which is then given the name of the key.
345 @param mode: Determines whether it is a one- or
346 two-dimensional histogram.
347 Accepted values are '1D' and '2D'
348 @return: None
349 """
350
351 # If we don't get a valid 'mode', we can stop right here
352 if mode not in ["1D", "2D"]:
353 return
354
355 # Create a ROOT canvas on which we will draw our histograms
356 self._width = 700
357 if mode == "2D" and len(self._elements) > 4:
358 self._height = 1050
359 else:
360 self._height = 525
361 canvas = ROOT.TCanvas("", "", self._width, self._height)
362
363 # Create a ROOT Legend
364 legend = ROOT.TLegend(0.01, 0.01, 0.53, 0.06)
365 legend.SetNColumns(3)
366 legend.SetTextSize(0.02)
367
368 # Allow possibility to turn off the stats box
369 if self._mop.has_option("nostats"):
370 ROOT.gStyle.SetOptStat("")
371 else:
372 ROOT.gStyle.SetOptStat("nemr")
373
374 # If there is a reference object, and the list of plots is not empty,
375 # perform a Chi^2-Test on the reference object and the first object in
376 # the plot list:
377 if (
378 self._reference is not None
379 and self._newest
380 and not self._reference == self._newest
381 ):
382 self.perform_comparison()
383
384 # A variable which holds whether we
385 # have drawn on the canvas already or not
386 # (only used for the 1D case)
387 drawn = False
388
389 # Now we distinguish between 1D and 2D histograms
390 # If we have a 1D histogram
391 if mode == "1D":
392
393 if not self._mop.has_option("nogrid"):
394 canvas.SetGrid()
395 if self._mop.has_option("logx"):
396 canvas.SetLogx()
397 if self._mop.has_option("logy"):
398 canvas.SetLogy()
399
400 # If there is a reference object, plot it first
401 if self._reference is not None:
402 self._draw_ref(canvas)
403 drawn = True
404
405 # If we have a 2D histogram
406 elif mode == "2D":
407
408 # Split the canvas into enough parts to fit all histogram_objects
409 # Find numbers x and y so that x*y = N (number of histograms to be
410 # plotted), and x,y close to sqrt(N)
411
412 if len(self._root_objects) == 1:
413 x = y = 1
414 elif len(self._root_objects) == 2:
415 x = 2
416 y = 1
417 else:
418 x = 2
419 y = int(math.floor((len(self._root_objects) + 1) / 2))
420
421 # Actually split the canvas and go to the first pad ('sub-canvas')
422 canvas.Divide(x, y)
423 pad = canvas.cd(1)
424 pad.SetFillColor(ROOT.kWhite)
425
426 # If there is a reference object, plot it first
427 if self._reference is not None:
428 self._draw_ref(pad)
429
430 items_to_plot_count = len(self._elements)
431
432 # Now draw the normal plots
433 for plot in reversed(self._elements):
434
435 # Get the index of the current plot
436 index = index_from_revision(plot.revision, self._work_folder)
437 style = get_style(index, items_to_plot_count)
438
439 self._remove_stats_tf1(plot.object)
440
441 # Set line properties accordingly
442 plot.object.SetLineColor(style.GetLineColor())
443 plot.object.SetLineWidth(style.GetLineWidth())
444 plot.object.SetLineStyle(style.GetLineStyle())
445
446 # If we have a one-dimensional histogram
447 if mode == "1D":
448 if not drawn:
449 # Get additional options for 1D histograms
450 # (Intersection with self.metaoptions)
451 additional_options = ["C"]
452 additional_options = [
453 option
454 for option in additional_options
455 if self._mop.has_option(option)
456 ]
457 options_str = plot.object.GetOption() + " ".join(
458 additional_options
459 )
460 drawn = True
461 else:
462 options_str = "SAME"
463
464 legend.AddEntry(plot.object, plot.revision)
465
466 self._draw_root_object(self.type, plot.object, options_str)
467
468 # redraw grid on top of histogram, if selected
469 if not self._mop.has_option("nogrid"):
470 canvas.RedrawAxis("g")
471
472 canvas.Update()
473 canvas.GetFrame().SetFillColor(ROOT.kWhite)
474
475 # If we have a two-dimensional histogram
476 elif mode == "2D":
477 # Switch to the correct sub-panel of the canvas. If a ref-plot
478 # exists, we have to go one panel further compared to the
479 # no-ref-case
480 if self._reference is not None:
481 i = 2
482 else:
483 i = 1
484
485 pad = canvas.cd(self._elements.index(plot) + i)
486 pad.SetFillColor(ROOT.kWhite)
487
488 # Get additional options for 2D histograms
489 additional_options = ""
490 for _ in ["col", "colz", "cont", "contz", "box"]:
491 if self._mop.has_option(_):
492 additional_options += " " + _
493
494 # Draw the reference on the canvas
495 self._draw_root_object(
496 self.type,
497 plot.object,
498 plot.object.GetOption() + additional_options,
499 )
500 pad.Update()
501 pad.GetFrame().SetFillColor(ROOT.kWhite)
502
503 # Write the title in the correct color
504 title = pad.GetListOfPrimitives().FindObject("title")
505 if title:
506 title.SetTextColor(style.GetLineColor())
507
508 if self._newest:
509 # if there is at least one revision
510 self._set_background(canvas)
511
512 canvas.GetFrame().SetFillColor(ROOT.kWhite)
513
514 # Add reference legend entry last for neatness
515 if self._reference is not None:
516 legend.AddEntry(self._reference.object, self._reference.revision)
517
518 legend.Draw()
519
520 # Save the plot as PNG and PDF
521 canvas.Print(os.path.join(self._plot_folder, self.get_png_filename()))
522 canvas.Print(os.path.join(self._plot_folder, self.get_pdf_filename()))
523
524 self._file = os.path.join(
525 self._plot_folder,
526 f"{strip_ext(self.rootfile)}_{self.key}",
527 )
528
529 def create_graph_plot(self):
530 """!
531 Plots as TGraph/TGraphErrors
532 @return: None
533 """
534
535 # Create a ROOT canvas on which we will draw our plots
536 self._width = 700
537 self._height = 525
538 canvas = ROOT.TCanvas("", "", self._width, self._height)
539
540 # Allow possibility to turn off the stats box
541 if self._mop.has_option("nostats"):
542 ROOT.gStyle.SetOptStat("")
543 else:
544 ROOT.gStyle.SetOptStat("nemr")
545
546 # If there is a reference object, and the list of plots is not empty,
547 # perform a Chi^2-Test on the reference object and the first object in
548 # the plot list:
549 if (
550 self._reference is not None
551 and self._newest
552 and not self._reference == self._newest
553 ):
554 self.perform_comparison()
555
556 if not self._mop.has_option("nogrid"):
557 canvas.SetGrid()
558 if self._mop.has_option("logx"):
559 canvas.SetLogx()
560 if self._mop.has_option("logy"):
561 canvas.SetLogy()
562
563 # A variable which holds whether we
564 # have drawn on the canvas already or not
565 drawn = False
566
567 # If there is a reference object, plot it first
568 if self._reference is not None:
569 self._draw_ref(canvas)
570 drawn = True
571
572 items_to_plot_count = len(self._elements)
573 # Now draw the normal plots
574 for plot in reversed(self._elements):
575
576 # Get the index of the current plot
577 index = index_from_revision(plot.revision, self._work_folder)
578 style = get_style(index, items_to_plot_count)
579
580 # self.remove_stats_tf1(plot.object)
581
582 # Set line properties accordingly
583 plot.object.SetLineColor(style.GetLineColor())
584 plot.object.SetLineWidth(style.GetLineWidth())
585 plot.object.SetLineStyle(style.GetLineStyle())
586
587 # If we have a one-dimensional histogram
588 if not drawn:
589
590 # todo: refactor like in plot hist
591 # Get additional options for 1D histograms
592 additional_options = ""
593 for _ in ["C"]:
594 if self._mop.has_option(_):
595 additional_options += " " + _
596
597 # Draw the reference on the canvas
598 self._draw_root_object(
599 self.type,
600 plot.object,
601 plot.object.GetOption() + additional_options,
602 )
603 drawn = True
604 else:
605 self._draw_root_object(self.type, plot.object, "SAME")
606
607 # redraw grid on top of histogram, if selected
608 if not self._mop.has_option("nogrid"):
609 canvas.RedrawAxis("g")
610
611 canvas.Update()
612 canvas.GetFrame().SetFillColor(ROOT.kWhite)
613
614 if self._newest:
615 # if there is at least one revision
616 self._set_background(canvas)
617
618 # Save the plot as PNG and PDF
619 canvas.Print(os.path.join(self._plot_folder, self.get_png_filename()))
620 canvas.Print(os.path.join(self._plot_folder, self.get_pdf_filename()))
621
622 self._file = os.path.join(
623 self._plot_folder,
624 f"{strip_ext(self.rootfile)}_{self.key}",
625 )
626
627 def create_html_content(self):
628
629 # self.elements
630 self._html_content = ""
631
632 for elem in self._elements:
633 self._html_content += (
634 "<p>" + elem.revision + "</p>" + elem.object.GetTitle()
635 )
636
637 # there is no file storing this, because it is directly in the json
638 # file
639 self._file = None
640
641 def get_meta_information(self):
642 assert self.type == "meta"
643 key = self._newest.object.GetName().strip().lower()
644 value = self._newest.object.GetTitle()
645 return key, value
646
647 def create_ntuple_table_json(self):
648 """!
649 If the Plotuple-object contains n-tuples, this will create the
650 a JSON file, which is later converted to HTML by the javascript
651 function fillNtupleTable.
652 """
653
654 json_nutple = {}
655
656 # The dictionary will have the following form
657 # {
658 # "reference (if exist)": [
659 # ('variable 1', 'reference value for variable 1'),
660 # ('variable 2', 'reference value for variable 2'),
661 # ...
662 # ],
663 # "revision": [
664 # ...
665 # ]
666 # }
667
668 precision = self._mop.int_value("float-precision", default=4)
669 format_str = f"{{0:.{precision}f}}"
670
671 def value2str(obj):
672 # assuming that I have a float
673 return format_str.format(obj)
674
675 colum_names = []
676 for key in list(self._newest.object.keys()):
677 colum_names.append(key)
678
679 # If there is a reference object, print the reference values as the
680 # first row of the table
681 if self._reference and "reference" in self._revisions:
682 json_nutple["reference"] = []
683
684 key_list = list(self._reference.object.keys())
685 for column in colum_names:
686 if column in key_list:
687 value_str = value2str(self._reference.object[column])
688 json_nutple["reference"].append((column, value_str))
689 else:
690 json_nutple["reference"].append((column, None))
691
692 # Now print the values for all other revisions
693 for ntuple in self._elements:
694 if ntuple.revision not in json_nutple:
695 json_nutple[ntuple.revision] = []
696
697 for column in colum_names:
698 if column in ntuple.object:
699 value_str = value2str(ntuple.object[column])
700 json_nutple[ntuple.revision].append((column, value_str))
701 else:
702 json_nutple[ntuple.revision].append((column, None))
703
704 json_ntuple_file = os.path.join(
705 self._plot_folder,
706 f"{strip_ext(self.rootfile)}_{self.key}.json",
707 )
708
709 with open(json_ntuple_file, "w+") as json_file:
710 json.dump(json_nutple, json_file)
711
712 self._file = json_ntuple_file
713
714 def get_plot_title(self):
715 if self._file:
716 return os.path.basename(self._file).replace(".", "_").strip()
717 else:
718 # this is for html content which is not stored in any file
719 return self.key
720
721 def create_json_object(self):
722 if self.type == "TNtuple":
724 title=self.get_plot_title(),
725 description=self._description,
726 contact=self._contact,
727 check=self._check,
728 is_expert=self.is_expert(),
729 json_file_path=os.path.relpath(
730 self._file,
731 validationpath.get_html_folder(self._work_folder),
732 ),
733 )
734 elif self.type == "TNamed":
736 title=self.get_plot_title(),
737 description=self._description,
738 contact=self._contact,
739 check=self._check,
740 is_expert=self.is_expert(),
741 html_content=self._html_content,
742 )
743 elif self.type == "meta":
744 return None
745 else:
747 title=self.get_plot_title(),
748 comparison_result=self.comparison_result,
749 comparison_text=self._comparison_result_long,
750 description=self._description,
751 contact=self._contact,
752 check=self._check,
753 height=self._height,
754 width=self._width,
755 is_expert=self.is_expert(),
756 plot_path=os.path.relpath(
757 self._plot_folder,
758 validationpath.get_html_folder(self._work_folder),
759 )
760 + "/",
761 png_filename=self.get_png_filename(),
762 pdf_filename=self.get_pdf_filename(),
763 warnings=self.warnings,
764 )
def get_html_folder(output_base_dir)
def get_html_plots_tag_comparison_folder(output_base_dir, tags)