diff --git a/quickstats/_version.py b/quickstats/_version.py index fe78b13f2df3dba3420174b30ac3945c0b79c497..3ebcf686435c5946013707a98db09434398fb09c 100644 --- a/quickstats/_version.py +++ b/quickstats/_version.py @@ -1 +1 @@ -__version__ = "0.7.0.2" +__version__ = "0.7.0.3" diff --git a/quickstats/analysis/data_preprocessing.py b/quickstats/analysis/data_preprocessing.py index 4fa822669a0bf73ad1f2287a9068b6561ae89c2f..a955e37cd27a8c1b579a00562aa3abdf842cdbcb 100644 --- a/quickstats/analysis/data_preprocessing.py +++ b/quickstats/analysis/data_preprocessing.py @@ -16,9 +16,9 @@ def fix_negative_weights(df, mode:Union[int, NegativeWeightMode, str]=0, return None mask = df[weight_col] < 0 if mode == NegativeWeightMode.SETZERO: - df[weight_col][mask] = 0 + df.loc[mask, weight_col] = 0 elif mode == NegativeWeightMode.SETABS: - df[weight_col][mask] = abs(df[weight_col][mask]) + df.loc[mask, weight_col] = abs(df[weight_col][mask]) def shuffle_arrays(*arrays, random_state:Optional[int]=None): if random_state < 0: diff --git a/quickstats/analysis/ntuple_process_tool.py b/quickstats/analysis/ntuple_process_tool.py index 1b4082d58e5f79d8b728ccf8f9f92e483dd3d080..0ad49e243b90264a9c6b7e9d977378d6f57cfa24 100644 --- a/quickstats/analysis/ntuple_process_tool.py +++ b/quickstats/analysis/ntuple_process_tool.py @@ -92,6 +92,7 @@ class NTupleProcessTool(ConfigurableObject): self.process_flags = [] self.cutflow_report = None + self.process_metadata = {} def load_sample_config(self, config_source:Union[Dict, str]): if isinstance(config_source, str): @@ -396,7 +397,16 @@ class NTupleProcessTool(ConfigurableObject): self.processor.global_variables['outdir'] = outdir self.prerun_process(sample_config) self.processor.run(sample_paths) + self.set_process_metadata(sample, sample_type, + self.processor.result_metadata.copy()) self.processor.clear_global_variables() + + def set_process_metadata(self, sample:str, + sample_type:str, + metadata:Dict): + if sample not in self.process_metadata: + self.process_metadata[sample] = {} + self.process_metadata[sample][sample_type] = metadata def merge_outputs(self, source_path_func:Callable, target_path_func:Callable, @@ -645,4 +655,27 @@ class NTupleProcessTool(ConfigurableObject): fig.suptitle(f"Sample: {sample}", fontsize=20, y=0.98) - plt.show() \ No newline at end of file + plt.show() + + def copy_remote_samples(self, samples:Optional[Union[List[str], str]]=None, + sample_types:Optional[Union[List[str], str]]=None, + cache:bool=True, + cachedir:str='/tmp', + parallel:bool=False): + if isinstance(samples, str): + samples = [samples] + if isinstance(sample_types, str): + sample_types = [sample_types] + paths = self.get_selected_paths(syst_themes=['Nominal'], + samples=samples, + sample_types=sample_types) + if not paths: + self.stdout.warning('No inputs matching the given conditions. Skipped.') + paths = paths['Nominal'] + filenames = [] + for sample in paths: + for sample_type in paths[sample]: + filenames.extend(paths[sample][sample_type]) + from quickstats.interface.root import TFile + TFile.copy_remote_files(filenames, cache=cache, + cachedir=cachedir, parallel=parallel) \ No newline at end of file diff --git a/quickstats/components/__init__.py b/quickstats/components/__init__.py index f2b2513a95ab93512a3a45f1208dd7e5339b6c09..ba5e81eb630cc84dd15910989facfe533870f97b 100644 --- a/quickstats/components/__init__.py +++ b/quickstats/components/__init__.py @@ -1,3 +1,6 @@ +import quickstats +quickstats.core.methods._require_module("ROOT", quickstats.core.methods.is_root_installed) + from .basics import * from .root_object import ROOTObject from .discrete_nuisance import DiscreteNuisance diff --git a/quickstats/components/likelihood.py b/quickstats/components/likelihood.py index a09b60de16e2bd22a1d5fe4e049f571d16847a08..6a8f6d8dfa4dbc91d1140ea287842e0a3498e362 100644 --- a/quickstats/components/likelihood.py +++ b/quickstats/components/likelihood.py @@ -50,24 +50,19 @@ class Likelihood(AnalysisObject): poi_val = cond_fit_result['mu'] if (qmu >= 0): ndof = len(poi_val) - if ndof == 1: - # ndof = 1 case - poi_name = list(poi_val)[0] - x0 = poi_val[poi_name] - if x0 == 0: - sign = np.sign(uncond_fit_result['muhat'][poi_name]) - significance = sign * math.sqrt(qmu) + poi_name = list(poi_val)[0] + mu = poi_val[poi_name] + mu_hat = uncond_fit_result['muhat'][poi_name] + # one-sided p-value + if mu == 0: + if (ndof == 1) and (mu_hat < 0): + pvalue = ROOT.Math.normal_cdf_c(-math.sqrt(qmu)) else: - significance = math.sqrt(qmu) - pvalue = 1 - ROOT.Math.normal_cdf(significance, 1, x0) + pvalue = ROOT.Math.chisquared_cdf_c(qmu, ndof) / 2 + # two-sided p-value else: - x0 = list(set(poi_val.values())) - if len(x0) > 1: - pvalue = None - significance = None - else: - pvalue = ROOT.Math.chisquared_cdf_c(qmu, ndof, x0[0]) - significance = ROOT.RooStats.PValueToSignificance(pvalue) + pvalue = ROOT.Math.chisquared_cdf_c(qmu, ndof) + significance = ROOT.RooStats.PValueToSignificance(pvalue) combined_fit_result['significance'] = significance combined_fit_result['pvalue'] = pvalue else: diff --git a/quickstats/components/processors/actions/formatter.py b/quickstats/components/processors/actions/formatter.py index eed7656ced878c4e6b1cfda4b1ff1cc9cfad055b..156dc579692eb09d251b3555447938d8d28e35c0 100644 --- a/quickstats/components/processors/actions/formatter.py +++ b/quickstats/components/processors/actions/formatter.py @@ -1,6 +1,6 @@ import re -from quickstats.utils.string_utils import split_str +from quickstats.utils.string_utils import split_str, str_to_bool ListRegex = re.compile(r"\[([^\[\]]+)\]") @@ -8,4 +8,7 @@ def ListFormatter(text:str): match = ListRegex.match(text) if not match: return [text] - return split_str(match.group(1), sep=',', strip=True, remove_empty=True) \ No newline at end of file + return split_str(match.group(1), sep=',', strip=True, remove_empty=True) + +def BoolFormatter(text:str): + return str_to_bool(text) \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_alias.py b/quickstats/components/processors/actions/rooproc_alias.py index 38dc36230f99f6fbb7955059f4b2e4ab66778b31..bb6027e6ec8da189f02258266fd6f1670b940277 100644 --- a/quickstats/components/processors/actions/rooproc_alias.py +++ b/quickstats/components/processors/actions/rooproc_alias.py @@ -1,11 +1,11 @@ -from typing import Optional +from typing import Optional, Dict import re -from .rooproc_rdf_action import RooProcRDFAction +from .rooproc_hybrid_action import RooProcHybridAction from .auxiliary import register_action @register_action -class RooProcAlias(RooProcRDFAction): +class RooProcAlias(RooProcHybridAction): NAME = "ALIAS" @@ -22,10 +22,20 @@ class RooProcAlias(RooProcRDFAction): raise RuntimeError(f"invalid expression {main_text}") alias = result.group(1) column_name = result.group(2) - return cls(alias=alias, column_name=column_name) - - def _execute(self, rdf:"ROOT.RDataFrame", **params): + return cls(alias=alias, column_name=column_name) + + def _execute(self, rdf:"ROOT.RDataFrame", processor:"quickstats.RooProcessor", **params): alias = params['alias'] column_name = params['column_name'] rdf_next = rdf.Alias(alias, column_name) - return rdf_next \ No newline at end of file + return rdf_next, processor + + def get_referenced_columns(self, global_vars:Optional[Dict]=None): + params = self.get_formatted_parameters(global_vars, strict=False) + column = params['column_name'] + return [column] + + def get_defined_columns(self, global_vars:Optional[Dict]=None): + params = self.get_formatted_parameters(global_vars, strict=False) + column = params['alias'] + return [column] \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_as_hdf.py b/quickstats/components/processors/actions/rooproc_as_hdf.py index 4161af18bcd6d44f77000822617f9093ce39276d..93f5befa4bf43b883dedc6a7c2701406400ba78b 100644 --- a/quickstats/components/processors/actions/rooproc_as_hdf.py +++ b/quickstats/components/processors/actions/rooproc_as_hdf.py @@ -7,7 +7,6 @@ from .auxiliary import register_action from quickstats import module_exist from quickstats.utils.common_utils import is_valid_file -from quickstats.utils.data_conversion import ConversionMode from quickstats.interface.root import RDataFrameBackend @register_action @@ -16,7 +15,8 @@ class RooProcAsHDF(RooProcOutputAction): NAME = "AS_HDF" def __init__(self, filename:str, key:str, - columns:Optional[List[str]]): + columns:Optional[List[str]], + exclude:Optional[List[str]]=None): super().__init__(filename=filename, columns=columns, key=key) @@ -31,8 +31,10 @@ class RooProcAsHDF(RooProcOutputAction): import awkward as ak import pandas as pd columns = params.get('columns', None) - columns = self.get_valid_columns(rdf, processor, columns=columns, - mode=ConversionMode.REMOVE_NON_STANDARD_TYPE) + exclude = params.get('exclude', None) + save_columns = self.get_save_columns(rdf, processor, columns=columns, + exclude=exclude, + mode="REMOVE_NON_STANDARD_TYPE") array = None if module_exist('awkward'): try: @@ -40,14 +42,14 @@ class RooProcAsHDF(RooProcOutputAction): # NB: RDF Dask/Spark does not support GetColumnType yet if processor.backend in [RDataFrameBackend.DASK, RDataFrameBackend.SPARK]: rdf.GetColumnType = rdf._headnode._localdf.GetColumnType - array = ak.from_rdataframe(rdf, columns=columns) + array = ak.from_rdataframe(rdf, columns=save_columns) array = ak.to_numpy(array) except: array = None processor.stdout.warning("Failed to convert output to numpy arrays with awkward backend. " "Falling back to use ROOT instead") if array is None: - array = rdf.AsNumpy(columns) + array = rdf.AsNumpy(save_columns) df = pd.DataFrame(array) self.makedirs(filename) df.to_hdf(filename, key=key) diff --git a/quickstats/components/processors/actions/rooproc_as_numpy.py b/quickstats/components/processors/actions/rooproc_as_numpy.py index d65620ba5f7aa493ca621c61583cca92952ae56c..55b292cf7c98ad483c1efc3effe545c16ee237c7 100644 --- a/quickstats/components/processors/actions/rooproc_as_numpy.py +++ b/quickstats/components/processors/actions/rooproc_as_numpy.py @@ -22,8 +22,10 @@ class RooProcAsNumpy(RooProcOutputAction): return rdf, processor processor.stdout.info(f'Writing output to "{filename}".') columns = params.get('columns', None) - columns = self.get_valid_columns(rdf, processor, columns=columns, - mode=ConversionMode.REMOVE_NON_STANDARD_TYPE) + exclude = params.get('exclude', None) + save_columns = self.get_save_columns(rdf, processor, columns=columns, + exclude=exclude, + mode="REMOVE_NON_STANDARD_TYPE") array = None if module_exist('awkward'): try: @@ -31,14 +33,14 @@ class RooProcAsNumpy(RooProcOutputAction): # NB: RDF Dask/Spark does not support GetColumnType yet if processor.backend in [RDataFrameBackend.DASK, RDataFrameBackend.SPARK]: rdf.GetColumnType = rdf._headnode._localdf.GetColumnType - array = ak.from_rdataframe(rdf, columns=columns) + array = ak.from_rdataframe(rdf, columns=save_columns) array = ak.to_numpy(array) except: array = None processor.stdout.warning("Failed to convert output to numpy arrays with awkward backend. " "Falling back to use ROOT instead") if array is None: - array = rdf.AsNumpy(columns) + array = rdf.AsNumpy(save_columns) self.makedirs(filename) np.save(filename, array) return rdf, processor \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_as_parquet.py b/quickstats/components/processors/actions/rooproc_as_parquet.py index b627370ee3ebbca35229f954e351901f61c8754f..2422d391c9075ca43e3c5f0b573a38351c279d13 100644 --- a/quickstats/components/processors/actions/rooproc_as_parquet.py +++ b/quickstats/components/processors/actions/rooproc_as_parquet.py @@ -21,16 +21,18 @@ class RooProcAsParquet(RooProcOutputAction): return rdf, processor processor.stdout.info(f'Writing output to "{filename}".') columns = params.get('columns', None) - columns = self.get_valid_columns(rdf, processor, columns=columns, - mode=ConversionMode.REMOVE_NON_STANDARD_TYPE) + exclude = params.get('exclude', None) + save_columns = self.get_save_columns(rdf, processor, columns=columns, + exclude=exclude, + mode="REMOVE_NON_STANDARD_TYPE") import awkward as ak try: # NB: RDF Dask/Spark does not support GetColumnType yet if processor.backend in [RDataFrameBackend.DASK, RDataFrameBackend.SPARK]: rdf.GetColumnType = rdf._headnode._localdf.GetColumnType - array = ak.from_rdataframe(rdf, columns=columns) + array = ak.from_rdataframe(rdf, columns=save_columns) except: - array = ak.Array(rdf.AsNumpy(columns)) + array = ak.Array(rdf.AsNumpy(save_columns)) self.makedirs(filename) ak.to_parquet(array, filename) return rdf, processor \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_awkward_array.py b/quickstats/components/processors/actions/rooproc_awkward_array.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/quickstats/components/processors/actions/rooproc_base_action.py b/quickstats/components/processors/actions/rooproc_base_action.py index 8b7aa2063c167d7c0c812e6982d129d9d12fe133..69101f33b99806115413366a8a8c5dca12e4402b 100644 --- a/quickstats/components/processors/actions/rooproc_base_action.py +++ b/quickstats/components/processors/actions/rooproc_base_action.py @@ -4,6 +4,8 @@ import re from quickstats.utils.py_utils import get_required_args +LITERAL_REGEX = re.compile(r"\${(\w+)}") + class RooProcBaseAction(object): NAME = None @@ -20,9 +22,15 @@ class RooProcBaseAction(object): @staticmethod def has_global_var(text:str): - return re.search(r"\${(\w+)}", text) is not None + if not isinstance(text, str): + text = str(text) + return LITERAL_REGEX.search(text) is not None + + @staticmethod + def _get_literals(s:str): + return LITERAL_REGEX.findall(s) - def get_formatted_parameters(self, global_vars:Optional[Dict]=None): + def get_formatted_parameters(self, global_vars:Optional[Dict]=None, strict:bool=True): if global_vars is None: global_vars = {} formatted_parameters = {} @@ -30,26 +38,35 @@ class RooProcBaseAction(object): if v is None: formatted_parameters[k] = None continue - k_literals = re.findall(r"\${(\w+)}", k) + k_literals = self._get_literals(k) is_list = False if isinstance(v, list): v = '__SEPARATOR__'.join(v) is_list = True - v_literals = re.findall(r"\${(\w+)}", v) + elif not isinstance(v, str): + formatted_parameters[k] = v + continue + v_literals = self._get_literals(v) all_literals = set(k_literals).union(set(v_literals)) for literal in all_literals: - if literal not in global_vars: + if strict and (literal not in global_vars): raise RuntimeError(f"the global variable `{literal}` is undefined") for literal in k_literals: + if literal not in global_vars: + continue substitute = global_vars[literal] k = k.replace("${" + literal + "}", str(substitute)) for literal in v_literals: + if literal not in global_vars: + continue substitute = global_vars[literal] v = v.replace("${" + literal + "}", str(substitute)) if is_list: v = v.split("__SEPARATOR__") formatted_parameters[k] = v for key, value in formatted_parameters.items(): + if not isinstance(value, str): + continue if key in self.PARAM_FORMATS: formatter = self.PARAM_FORMATS[key] formatted_parameters[key] = formatter(value) @@ -100,4 +117,10 @@ class RooProcBaseAction(object): argnames = get_required_args(cls) missing_argnames = list(set(argnames) - set(kwargs)) raise ValueError(f'missing keyword argument(s) for the action "{cls.NAME}": ' - f'{", ".join(missing_argnames)}') \ No newline at end of file + f'{", ".join(missing_argnames)}') + + def get_referenced_columns(self, global_vars:Optional[Dict]=None): + return [] + + def get_defined_columns(self, global_vars:Optional[Dict]=None): + return [] \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_define.py b/quickstats/components/processors/actions/rooproc_define.py index 15ff70f12a2de3f7816bc92c0c2610231b56ae3b..ff8a3e8013acadf88ec6a890be5ddd18e5426ba7 100644 --- a/quickstats/components/processors/actions/rooproc_define.py +++ b/quickstats/components/processors/actions/rooproc_define.py @@ -1,6 +1,7 @@ -from typing import Optional +from typing import Optional, Dict import re +from quickstats.utils.string_utils import extract_variable_names from .rooproc_rdf_action import RooProcRDFAction from .auxiliary import register_action @@ -26,4 +27,21 @@ class RooProcDefine(RooProcRDFAction): name = params['name'] expression = params['expression'] rdf_next = rdf.Define(name, expression) - return rdf_next \ No newline at end of file + return rdf_next + + def get_referenced_columns(self, global_vars:Optional[Dict]=None): + params = self.get_formatted_parameters(global_vars, strict=False) + expr = params['expression'] + # need to remove global variables from the variable search + literals = self._get_literals(expr) + for literal in literals: + expr = expr.replace("${" + literal + "}", "1") + literals = ["${" + literal + "}" for literal in literals] + referenced_columns = extract_variable_names(expr) + referenced_columns.extend(literals) + return referenced_columns + + def get_defined_columns(self, global_vars:Optional[Dict]=None): + params = self.get_formatted_parameters(global_vars, strict=False) + defined_columns = [params['name']] + return defined_columns \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_filter.py b/quickstats/components/processors/actions/rooproc_filter.py index 8a0ef4e775452a65d2e8bad93df2b8a9a133df58..6e653a4041e78159a3059d6826a45948ca210ec0 100644 --- a/quickstats/components/processors/actions/rooproc_filter.py +++ b/quickstats/components/processors/actions/rooproc_filter.py @@ -17,8 +17,8 @@ class RooProcFilter(RooProcRDFAction): def parse(cls, main_text:str, block_text:Optional[str]=None): name_literals = re.findall(r"@{([^{}]+)}", main_text) if len(name_literals) == 0: - name = None - expression = main_text.strip() + name = main_text.strip() + expression = name elif len(name_literals) == 1: name = name_literals[0] expression = main_text.replace("@{" + name + "}", "").strip() diff --git a/quickstats/components/processors/actions/rooproc_output_action.py b/quickstats/components/processors/actions/rooproc_output_action.py index 611531125d02357bc3d00c2647ad834bfe76b647..567f0f6d14613326f3d64a87bbe3251907549d58 100644 --- a/quickstats/components/processors/actions/rooproc_output_action.py +++ b/quickstats/components/processors/actions/rooproc_output_action.py @@ -1,4 +1,4 @@ -from typing import Optional, List +from typing import Optional, List, Dict import fnmatch import numpy as np @@ -7,17 +7,19 @@ from .rooproc_hybrid_action import RooProcHybridAction from .formatter import ListFormatter from quickstats.interface.root import RDataFrameBackend -from quickstats.utils.common_utils import is_valid_file +from quickstats.utils.common_utils import is_valid_file, filter_by_wildcards from quickstats.utils.data_conversion import root_datatypes, get_rdf_column_type, ConversionMode, reduce_vector_types class RooProcOutputAction(RooProcHybridAction): PARAM_FORMATS = { - 'columns': ListFormatter + 'columns': ListFormatter, + 'exclude': ListFormatter } def __init__(self, filename:str, - columns:Optional[List[str]], + columns:Optional[List[str]]=None, + exclude:Optional[List[str]]=None, **kwargs): super().__init__(filename=filename, columns=columns, @@ -28,38 +30,45 @@ class RooProcOutputAction(RooProcHybridAction): kwargs = cls.parse_as_kwargs(main_text) return cls._try_create(**kwargs) - def get_valid_columns(self, rdf, processor, columns:Optional[List[str]]=None, - mode:ConversionMode=ConversionMode.REMOVE_NON_STANDARD_TYPE): + def get_save_columns(self, rdf, processor, + columns:Optional[List[str]]=None, + exclude:Optional[List[str]]=None, + mode:ConversionMode=ConversionMode.REMOVE_NON_STANDARD_TYPE): all_columns = list([str(col) for col in rdf.GetColumnNames()]) + + save_columns = filter_by_wildcards(all_columns, columns) + save_columns = filter_by_wildcards(save_columns, exclude, exclusion=True) + save_columns = list(set(save_columns)) + if columns is None: - columns = all_columns - else: - columns_ = [] - for column in columns: - if "*" in column: - matched_columns = fnmatch.filter(all_columns, column) - if not matched_columns: - processor.stdout.warning(f'No columns matching the expression "{column}". ' - 'It will be excluded from the output') - columns_.extend(matched_columns) - elif column not in all_columns: - processor.stdout.warning(f'Column "{column}" does not exist. ' - 'It will be excluded from the output') - else: - columns_.append(column) - columns = columns_ + columns = list(all_columns) + if exclude is None: + exclude = [] + + save_columns = filter_by_wildcards(all_columns, columns) + save_columns = filter_by_wildcards(save_columns, exclude, exclusion=True) + mode = ConversionMode.parse(mode) if mode in [ConversionMode.REMOVE_NON_STANDARD_TYPE, ConversionMode.REMOVE_NON_ARRAY_TYPE]: - column_types = np.array([get_rdf_column_type(rdf, col) for col in columns]) - + column_types = np.array([get_rdf_column_type(rdf, col) for col in save_columns]) if mode == ConversionMode.REMOVE_NON_ARRAY_TYPE: column_types = reduce_vector_types(column_types) - new_columns = list(np.array(columns)[np.where(np.isin(column_types, root_datatypes))]) - removed_columns = np.setdiff1d(columns, new_columns) + new_columns = list(np.array(save_columns)[np.where(np.isin(column_types, root_datatypes))]) + removed_columns = np.setdiff1d(save_columns, new_columns) if len(removed_columns) > 0: col_str = ", ".join(removed_columns) processor.stdout.warning("The following column(s) will be excluded from the output as they have " f"data types incompatible with the output format: {col_str}") - columns = new_columns + save_columns = new_columns + return save_columns + + def get_referenced_columns(self, global_vars:Optional[Dict]=None): + params = self.get_formatted_parameters(global_vars, strict=False) + columns = params.get("columns", None) + if columns is None: + columns = ["*"] + exclude = params.get("exclude", None) + if exclude is not None: + self.stdout.warning("Column exclusion will not be applied when inferring referenced columns") return columns \ No newline at end of file diff --git a/quickstats/components/processors/actions/rooproc_report.py b/quickstats/components/processors/actions/rooproc_report.py index d7a60d721e15d4e7c39b68bb9a1fcb72ff3b68c8..c74a24c83650df99c89c1532fdf4bea702a8920f 100644 --- a/quickstats/components/processors/actions/rooproc_report.py +++ b/quickstats/components/processors/actions/rooproc_report.py @@ -4,6 +4,7 @@ import pandas as pd from .rooproc_hybrid_action import RooProcHybridAction from .auxiliary import register_action +from .formatter import BoolFormatter from quickstats.utils.common_utils import is_valid_file @@ -11,6 +12,10 @@ from quickstats.utils.common_utils import is_valid_file class RooProcReport(RooProcHybridAction): NAME = "REPORT" + + PARAM_FORMATS = { + 'display': BoolFormatter + } def __init__(self, display:bool=False, filename:Optional[str]=None): super().__init__(display=display, diff --git a/quickstats/components/processors/actions/rooproc_safe_alias.py b/quickstats/components/processors/actions/rooproc_safe_alias.py index 9c50e043330a4c5629b7f5834a38cf4d6e62031e..cd16b9080a358d9b0c829f25acb8f8df7f4c543f 100644 --- a/quickstats/components/processors/actions/rooproc_safe_alias.py +++ b/quickstats/components/processors/actions/rooproc_safe_alias.py @@ -1,29 +1,14 @@ -from typing import Optional +from typing import Optional, Dict import re -from .rooproc_hybrid_action import RooProcHybridAction +from .rooproc_alias import RooProcAlias from .auxiliary import register_action @register_action -class RooProcSafeAlias(RooProcHybridAction): +class RooProcSafeAlias(RooProcAlias): NAME = "SAFEALIAS" - def __init__(self, alias:str, column_name:str): - super().__init__(alias=alias, column_name=column_name) - - @classmethod - def parse(cls, main_text:str, block_text:Optional[str]=None): - result = re.search(r"^\s*(\w+)\s*=\s*([\w\.\${}]+)\s*$", main_text) - if not result: - if re.search(r"^\s*(\w+)\s*=\s*([\w\.\${}]+)", main_text): - raise RuntimeError(f'can not alias an expression ("{main_text}"), ' - 'please use DEFINE instead') - raise RuntimeError(f"invalid expression {main_text}") - alias = result.group(1) - column_name = result.group(2) - return cls(alias=alias, column_name=column_name) - def _execute(self, rdf:"ROOT.RDataFrame", processor:"quickstats.RooProcessor", **params): alias = params['alias'] column_name = params['column_name'] diff --git a/quickstats/components/processors/actions/rooproc_save.py b/quickstats/components/processors/actions/rooproc_save.py index ffb8f19c8249f6ca827dde6b33a10f88707138eb..629139eb3c42aed9e94933b8c457c079b4389871 100644 --- a/quickstats/components/processors/actions/rooproc_save.py +++ b/quickstats/components/processors/actions/rooproc_save.py @@ -1,30 +1,24 @@ from typing import Optional, List import fnmatch -from .rooproc_hybrid_action import RooProcHybridAction +from .rooproc_output_action import RooProcOutputAction from .auxiliary import register_action +from .formatter import ListFormatter from quickstats.utils.common_utils import is_valid_file, filter_by_wildcards @register_action -class RooProcSave(RooProcHybridAction): +class RooProcSave(RooProcOutputAction): NAME = "SAVE" def __init__(self, treename:str, filename:str, columns:Optional[List[str]]=None, - exclude:Optional[List[str]]=None, - frame:Optional[str]=None): + exclude:Optional[List[str]]=None): super().__init__(treename=treename, filename=filename, columns=columns, - exclude=exclude, - frame=frame) - - @classmethod - def parse(cls, main_text:str, block_text:Optional[str]=None): - kwargs = cls.parse_as_kwargs(main_text) - return cls(**kwargs) + exclude=exclude) def _execute(self, rdf:"ROOT.RDataFrame", processor:"quickstats.RooProcessor", **params): treename = params['treename'] @@ -32,20 +26,14 @@ class RooProcSave(RooProcHybridAction): if processor.cache and is_valid_file(filename): processor.stdout.info(f'INFO: Cached output from "{filename}".') return rdf, processor - all_columns = [str(c) for c in rdf.GetColumnNames()] columns = params.get('columns', None) exclude = params.get('exclude', None) - self.makedirs(filename) - if isinstance(columns, str): - columns = self.parse_as_list(columns) - if columns is None: - columns = list(all_columns) - if exclude is None: - exclude = [] - save_columns = filter_by_wildcards(all_columns, columns) - save_columns = filter_by_wildcards(save_columns, exclude, exclusion=True) - save_columns = list(set(save_columns)) + save_columns = self.get_save_columns(rdf, processor, + columns=columns, + exclude=exclude, + mode="ALL") processor.stdout.info(f'Writing output to "{filename}".') + self.makedirs(filename) if processor.use_template: from quickstats.utils.root_utils import templated_rdf_snapshot rdf_next = templated_rdf_snapshot(rdf, save_columns)(treename, filename, save_columns) diff --git a/quickstats/components/processors/actions/rooproc_stat.py b/quickstats/components/processors/actions/rooproc_stat.py index e2719f01c595ddc7cd4980e003e57166a66d9739..0d156a2ad743ec96047c025bba641e64fda7b7ef 100644 --- a/quickstats/components/processors/actions/rooproc_stat.py +++ b/quickstats/components/processors/actions/rooproc_stat.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Dict import re from .rooproc_hybrid_action import RooProcHybridAction @@ -24,4 +24,9 @@ class RooProcStat(RooProcHybridAction): ext_var_name = params['ext_var_name'] column_name = params['column_name'] processor.external_variables[ext_var_name] = self._get_func(rdf)(column_name) - return rdf, processor \ No newline at end of file + return rdf, processor + + def get_referenced_columns(self, global_vars:Optional[Dict]=None): + params = self.get_formatted_parameters(global_vars, strict=False) + referenced_columns = [params['column_name']] + return referenced_columns \ No newline at end of file diff --git a/quickstats/components/processors/roo_process_config.py b/quickstats/components/processors/roo_process_config.py index 51c44c4a4d02e05416b165e62771de600946d8bf..3d625a40ed790ced11e472a39908f8d609ca4cb2 100644 --- a/quickstats/components/processors/roo_process_config.py +++ b/quickstats/components/processors/roo_process_config.py @@ -1,12 +1,13 @@ -from typing import List, Optional +from typing import List, Optional, Dict import os import re from quickstats import semistaticmethod, TVirtualNode, TVirtualTree, stdout from quickstats.utils.string_utils import split_lines, split_str +from quickstats.utils.common_utils import combine_dict, remove_duplicates from quickstats.interface.root import RDataFrameBackend -from .actions import RooProcBaseAction, RooProcNestedAction, get_action +from .actions import RooProcBaseAction, RooProcNestedAction, get_action, RooProcGlobalVariables def _get_action(name:str, rdf_backend:Optional[str]=None): if name.lower() == "alias": @@ -17,6 +18,9 @@ def _get_action(name:str, rdf_backend:Optional[str]=None): name = "DEFINE" return get_action(name) +def _format_multiline_string(s:str): + return (s.split('\n')[0] + '...') if '\n' in s else s + class ActionNode(TVirtualNode): def __init__(self, name:Optional[str]=None, level:Optional[int]=0, @@ -25,6 +29,23 @@ class ActionNode(TVirtualNode): super().__init__(name=name, level=level, parent=parent, **data) self.action = None + + def __repr__(self): + class_name = self.__class__.__name__ + attributes = {} + attributes['name'] = self.name + for key in ['main_text', 'block_text']: + value = self.data.get(key, None) + if not value: + continue + attributes[key] = _format_multiline_string(value) + attributes['level'] = self.level + attributes['source'] = self.data.get('source', '') + attributes['start_line_number'] = self.data.get('start_line_number', '') + attributes['end_line_number'] = self.data.get('end_line_number', '') + attributes['children'] = '[...]' if self.children else '[]' + attribute_str = ", ".join([f"{k}={v}" for k, v in attributes.items()]) + return (f"{class_name}({attribute_str})") def get_context(self): source = self.try_get_data("source", None) @@ -45,7 +66,7 @@ class ActionNode(TVirtualNode): main_text = self.get_data("main_text") block_text = self.get_data("block_text") action = action_cls.parse(main_text=main_text, block_text=block_text) - self.action = action + self.action = action class ActionTree(TVirtualTree): @@ -64,6 +85,53 @@ class ActionTree(TVirtualTree): node = self.get_next() self.reset() + def _get_columns(self, col_func, global_vars:Optional[Dict]=None, + exclude_global:bool=True): + # make a copy + global_vars = combine_dict(global_vars) + current_node = self.current_node + self.reset() + node = self.get_next() + columns = set() + while node is not None: + action = node.action + if action is None: + raise RuntimeError(f'Action not set for the node: {node}') + if isinstance(action, RooProcGlobalVariables): + params = action.get_formatted_parameters(global_vars) + global_vars.update(params) + columns |= set(col_func(node, global_vars)) + if 'phi' in columns: + from pdb import set_trace + set_trace() + node = self.get_next() + self.current_node = current_node + columns = list(columns) + if exclude_global: + columns = [col for col in columns \ + if not RooProcBaseAction.has_global_var(col)] + return columns + + def get_referenced_columns(self, global_vars:Optional[Dict]=None, + exclude_defined:bool=True, + exclude_global:bool=True): + col_func = lambda node, glob_vars_: node.action.get_referenced_columns(glob_vars_) + referenced_columns = self._get_columns(col_func, global_vars, + exclude_global=exclude_global) + if exclude_defined: + defined_columns = self.get_defined_columns(global_vars=global_vars, + exclude_global=False) + referenced_columns = [col for col in referenced_columns \ + if col not in defined_columns] + return referenced_columns + + def get_defined_columns(self, global_vars:Optional[Dict]=None, + exclude_global:bool=True): + col_func = lambda node, glob_vars_: node.action.get_defined_columns(glob_vars_) + return self._get_columns(col_func, global_vars, + exclude_global=exclude_global) + + class RooConfigLine(object): def __init__(self, text:str, line_number:int): diff --git a/quickstats/components/processors/roo_processor.py b/quickstats/components/processors/roo_processor.py index 5ad1e1191702080a757d23375403f6d1073301f8..3a886079edfadf2ea1f3fe3c14182e1137b4e4bc 100644 --- a/quickstats/components/processors/roo_processor.py +++ b/quickstats/components/processors/roo_processor.py @@ -9,13 +9,27 @@ from .builtin_methods import BUILTIN_METHODS from .actions import * from .roo_process_config import RooProcessConfig -from quickstats import timer, AbstractObject, PathManager +from quickstats import timer, AbstractObject, PathManager, GeneralEnum from quickstats.interface.root import TFile, RDataFrame, RDataFrameBackend from quickstats.interface.xrootd import get_cachedir, set_cachedir, switch_cachedir -from quickstats.utils.root_utils import declare_expression, close_all_root_files +from quickstats.utils.root_utils import declare_expression, close_all_root_files, set_multithread from quickstats.utils.path_utils import is_remote_path from quickstats.utils.common_utils import get_cpu_count +class RDFVerbosity(GeneralEnum): + UNSET = (0, 'kUnset') + FATAL = (1, 'kFatal') + ERROR = (2, 'kError') + WARNING = (3, 'kWarning') + INFO = (4, 'kInfo') + DEBUG = (5, 'kDebug') + + def __new__(cls, value:int, key:str): + obj = object.__new__(cls) + obj._value_ = value + obj.key = key + return obj + class RooProcessor(AbstractObject): @property @@ -44,7 +58,8 @@ class RooProcessor(AbstractObject): self.external_variables = {} self.default_treename = None self.use_template = use_template - self.multithread = multithread + self.rdf_verbosity = None + self.result_metadata = None if backend is None: self.backend = RDataFrameBackend.DEFAULT else: @@ -52,22 +67,23 @@ class RooProcessor(AbstractObject): self.backend_options = backend_options self.set_remote_file_options(localize=False, cachedir=get_cachedir()) - + self.set_profile_options() self.load_buildin_functions() - - if multithread: - if multithread > 1: - ROOT.EnableImplicitMT(multithread) - num_thread = multithread - else: - ROOT.EnableImplicitMT() - num_thread = get_cpu_count() - self.stdout.info(f'Enabled multithreading with {num_thread} threads.') - elif ROOT.IsImplicitMTEnabled(): - ROOT.DisableImplicitMT() + + self.set_multithread(multithread) if config_source is not None: self.load_config(config_source) + + def set_multithread(self, num_threads:Optional[int]=None): + if num_threads is None: + num_threads = self.multithread + num_threads = set_multithread(num_threads) + if num_threads is None: + self.stdout.info("Disabled multithreading.") + else: + self.stdout.info(f"Enabled multithreading with {num_threads} threads.") + self.multithread = num_threads def set_cache(self, cache:bool=True): self.cache = cache @@ -82,6 +98,12 @@ class RooProcessor(AbstractObject): 'copy_options': copy_options } self.remote_file_options = remote_file_options + + def set_profile_options(self, throughput:bool=False): + profile_options = { + "throughput": throughput + } + self.profile_options = profile_options def load_buildin_functions(self): # bug of redefining module from ROOT @@ -156,7 +178,9 @@ class RooProcessor(AbstractObject): raise RuntimeError("action tree not initialized") node = self.action_tree.get_next(consider_child=consider_child) if node is not None: - self.stdout.debug(f'Executing node "{node.name}" defined at line {node.data["start_line_number"]}') + source = node.try_get_data("source", None) + self.stdout.debug(f'Executing node "{node.name}" defined at line {node.data["start_line_number"]}' + f' (source {source})') action = node.action return_code = self.run_action(action) if return_code == RooProcReturnCode.NORMAL: @@ -186,37 +210,42 @@ class RooProcessor(AbstractObject): raise_on_error=False) return files - def _fetch_remote_files(self, filenames:List[str]): + def resolve_filenames(self, filenames:Union[List[str], str]): + filenames = self.list_files(filenames, resolve_cache=True) + if not filenames: + return [] + has_remote_file = self._has_remote_files(filenames) + # copy remote files to local storage + if has_remote_file and self.remote_file_options['localize']: + remote_files = [filename for filename in filenames if is_remote_path(filename)] + self._copy_remote_files(remote_files) + filenames = self.list_files(filenames, resolve_cache=True) + return filenames + + def _copy_remote_files(self, filenames:List[str]): opts = self.remote_file_options copy_options = opts.get('copy_options', None) if copy_options is None: copy_options = {} - TFile.fetch_remote_files(filenames, cache=opts['cache'], + TFile.copy_remote_files(filenames, cache=opts['cache'], cachedir=opts['cachedir'], **copy_options) - def load_rdataframe(self, - filenames:Union[List[str], str], - treename:Optional[str]=None): - - if treename is None: - treename = self.default_treename + def load_rdf(self, + filenames:Union[List[str], str], + treename:Optional[str]=None): - if treename is None: - raise RuntimeError("treename is undefined") - - filenames = self.list_files(filenames, resolve_cache=True) - + filenames = self.resolve_filenames(filenames) if not filenames: - self.stdout.info('No files to be processed. Skipped.') + self.stdout.info('No files to be processed. Skipping.') return None - - has_remote_file = self._has_remote_files(filenames) - # copy remote files to local storage - if has_remote_file and self.remote_file_options['localize']: - remote_files = [filename for filename in filenames if is_remote_path(filename)] - self._fetch_remote_files(remote_files) - filenames = self.list_files(filenames, resolve_cache=True) + self._filenames = filenames + + if treename is None: + treename = self.default_treename + if treename is None: + treename = TFile._get_main_treename(filenames[0]) + self.stdout.info(f"Using deduced treename: {treename}") if len(filenames) == 1: self.stdout.info(f'Processing file "{filenames[0]}".') @@ -236,19 +265,58 @@ class RooProcessor(AbstractObject): self.sanity_check() with timer() as t: if filenames is not None: - self.load_rdataframe(filenames) + self.load_rdf(filenames) self.action_tree.reset() self.run_all_actions() self.shallow_cleanup() self.stdout.info(f"Task finished. Total time taken: {t.interval:.3f} s.") + result_metadata = { + "files": list(self._filenames), + "real_time": t.real_time_elapsed, + "cpu_time": t.cpu_time_elapsed + } + self.result_metadata = result_metadata return self + + def get_rdf(self, frame:Optional[str]=None): + rdf = self.rdf if frame is None else self.rdf_frames.get(frame, None) + if rdf is None: + raise RuntimeError('RDataFrame instance not initialized') + return rdf + + def get_referenced_columns(self): + action_tree = self.action_tree + return action_tree.get_referenced_columns(self.global_variables) def awkward_array(self, frame:Optional[str]=None, - columns:Optional[List[str]]=None): - if frame is None: - rdf = self.rdf + columns:Optional[List[str]]=None): + rdf = self.get_rdf(frame) + return RDataFrame._awkward_array(rdf, columns=columns) + + def display(self, frame:Optional[str]=None, + columns:Union[str, List[str]]="", + n_rows:int=5, n_max_collection_elements:int=10, + lazy:bool=False): + rdf = self.get_rdf(frame) + result = self.rdf.Display(columns, n_rows, n_max_collection_elements) + if not lazy: + result.Print() + return None + return result + + def save_graph(self, frame:Optional[str]=None, + filename:Optional[str]=None): + rdf = self.get_rdf(frame) + if filename: + ROOT.RDF.SaveGraph(rdf, filename) else: - rdf = self.rdf_frames.get(frame, None) - if rdf is None: - raise RuntimeError('RDataFrame instance not initialized') - return RDataFrame._awkward_array(rdf, columns=columns) \ No newline at end of file + ROOT.RDF.SaveGraph(rdf) + + def set_rdf_verbosity(self, verbosity:str='INFO'): + if isinstance(verbosity, str): + verbosity = RDFVerbosity.parse(verbosity) + loglevel = getattr(ROOT.Experimental.ELogLevel, verbosity.key) + else: + loglevel = verbosity + verb = ROOT.Experimental.RLogScopedVerbosity(ROOT.Detail.RDF.RDFLogChannel(), loglevel) + self.rdf_verbosity = verb \ No newline at end of file diff --git a/quickstats/concurrent/parameterised_asymptotic_cls.py b/quickstats/concurrent/parameterised_asymptotic_cls.py index 306fe774171f28d7906572b42ee4c579b2768286..2e579b88a99df3953a4a53b3e7be5791f9bd7550 100644 --- a/quickstats/concurrent/parameterised_asymptotic_cls.py +++ b/quickstats/concurrent/parameterised_asymptotic_cls.py @@ -7,7 +7,7 @@ from itertools import repeat from quickstats import semistaticmethod from quickstats.parsers import ParamParser from quickstats.concurrent import ParameterisedRunner -from quickstats.utils.common_utils import batch_makedirs, json_load, combine_dict, save_as_json +from quickstats.utils.common_utils import batch_makedirs, json_load, combine_dict, save_json from quickstats.components import AsymptoticCLs class ParameterisedAsymptoticCLs(ParameterisedRunner): @@ -117,4 +117,4 @@ class ParameterisedAsymptoticCLs(ParameterisedRunner): if outname is not None: outpath = os.path.join(outdir, outname) - save_as_json(final_result, outpath) \ No newline at end of file + save_json(final_result, outpath) \ No newline at end of file diff --git a/quickstats/concurrent/parameterised_likelihood.py b/quickstats/concurrent/parameterised_likelihood.py index 6f7439ac399ee6a14fee0d7cfcca2eda01ff1b90..31ef480816a1e1828a9ff7773291a06a851c3ac7 100644 --- a/quickstats/concurrent/parameterised_likelihood.py +++ b/quickstats/concurrent/parameterised_likelihood.py @@ -10,7 +10,7 @@ import ROOT from quickstats import semistaticmethod from quickstats.parsers import ParamParser from quickstats.concurrent import ParameterisedRunner -from quickstats.utils.common_utils import batch_makedirs, save_as_json +from quickstats.utils.common_utils import batch_makedirs, save_json from quickstats.components import Likelihood class ParameterisedLikelihood(ParameterisedRunner): @@ -235,4 +235,4 @@ class ParameterisedLikelihood(ParameterisedRunner): outdir = self.attributes['outdir'] outname = self.attributes['outname'].format(poi_names="_".join(poi_names)) outpath = os.path.join(outdir, outname.format(poi_name=poi_name)) - save_as_json(data, outpath) \ No newline at end of file + save_json(data, outpath) \ No newline at end of file diff --git a/quickstats/concurrent/parameterised_significance.py b/quickstats/concurrent/parameterised_significance.py index 646573dd4a68cbfdd864c670ebc2756d920f6e58..38e61ee9df987bc27750914e233cefe975dbf2a7 100644 --- a/quickstats/concurrent/parameterised_significance.py +++ b/quickstats/concurrent/parameterised_significance.py @@ -6,7 +6,7 @@ from typing import Optional, Union, Dict, List, Any from quickstats import semistaticmethod from quickstats.parsers import ParamParser from quickstats.concurrent import ParameterisedRunner -from quickstats.utils.common_utils import batch_makedirs, list_of_dict_to_dict_of_list, save_as_json, combine_dict +from quickstats.utils.common_utils import batch_makedirs, list_of_dict_to_dict_of_list, save_json, combine_dict from quickstats.maths.numerics import pretty_value from quickstats.components import AnalysisBase, AsimovType, AsimovGenerator @@ -84,9 +84,11 @@ class ParameterisedSignificance(ParameterisedRunner): asimov_snapshot = AsimovGenerator.ASIMOV_SETTINGS[asimov_type]['asimov_snapshot'] analysis.set_data(asimov_data) result = analysis.nll_fit(poi_val=mu_exp, mode='hybrid', - snapshot_name=asimov_snapshot, do_minos=config['minos'] if "minos" in config else False) + snapshot_name=asimov_snapshot, + do_minos=config.get('minos', None)) else: - result = analysis.nll_fit(poi_val=mu_exp, mode='hybrid', do_minos=config['minos'] if "minos" in config else False) + result = analysis.nll_fit(poi_val=mu_exp, mode='hybrid', + do_minos=config.get('minos', None)) if outname: with open(outname, 'w') as outfile: json.dump(result, outfile, indent=2) @@ -164,4 +166,4 @@ class ParameterisedSignificance(ParameterisedRunner): outdir = self.attributes['outdir'] outname = self.attributes['outname'].format(param_names="_".join(param_names)) outpath = os.path.join(outdir, outname) - save_as_json(data, outpath) \ No newline at end of file + save_json(data, outpath) \ No newline at end of file diff --git a/quickstats/core/__init__.py b/quickstats/core/__init__.py index c01ae6427e44b7081c13263c296a5927bfcdefd3..9d9d1b60059b2b836e852299545aa03f128c829c 100644 --- a/quickstats/core/__init__.py +++ b/quickstats/core/__init__.py @@ -4,7 +4,7 @@ from .abstract_object import AbstractObject from .enums import GeneralEnum, DescriptiveEnum from .virtual_trees import TVirtualNode, TVirtualTree from .path_manager import DynamicFilePath, PathManager -from .configurations import * +from .configuration import * #from .configs import ConfigComponent, ConfigParser, ConfigurableObject from .methods import * from .setup import * \ No newline at end of file diff --git a/quickstats/core/configurations.py b/quickstats/core/configuration.py similarity index 99% rename from quickstats/core/configurations.py rename to quickstats/core/configuration.py index 0e2a4bd9fafae4b70657dee3c901e4ff8a6a86a3..4ef0c91fc42936021f6452c39b817efd4211b6e2 100644 --- a/quickstats/core/configurations.py +++ b/quickstats/core/configuration.py @@ -18,7 +18,7 @@ from .type_validation import get_type_validator, get_type_hint_str from quickstats.utils.string_utils import format_dict_to_string -__all__ = ['as_dict', 'ConfigComponent', 'ConfigScheme', 'ConfigFile', 'ConfigurableObject', 'ConfigUnit'] +__all__ = ['ConfigComponent', 'ConfigScheme', 'ConfigFile', 'ConfigurableObject', 'ConfigUnit'] class MISSING_TYPE: diff --git a/quickstats/core/decorators.py b/quickstats/core/decorators.py index 52f4fe117366c05d04649cf2826ed05c0f2e4925..3403bc7f6646982a590ea96ba9d9cbf8c2281581 100644 --- a/quickstats/core/decorators.py +++ b/quickstats/core/decorators.py @@ -3,6 +3,8 @@ from functools import partial import time import importlib +__all__ = ["semistaticmethod", "cls_method_timer", "timer"] + class semistaticmethod(object): """ Descriptor to allow a staticmethod inside a class to use 'self' when called from an instance. @@ -110,7 +112,8 @@ class timer: Returns: timer: The timer instance itself. """ - self.start = time.time() + self.start_real = time.time() + self.start_cpu = time.process_time() return self def __exit__(self, *args): @@ -123,5 +126,8 @@ class timer: Returns: None """ - self.end = time.time() - self.interval = self.end - self.start \ No newline at end of file + self.end_cpu = time.process_time() + self.end_real = time.time() + self.interval = self.end_real - self.start_real + self.real_time_elapsed = self.interval + self.cpu_time_elapsed = self.end_cpu - self.start_cpu \ No newline at end of file diff --git a/quickstats/core/enums.py b/quickstats/core/enums.py index 1163ccb93a5d5667ae6ad30387da6c65ff5af3f9..abf6af92495aa05d7b6eaff826702bd50d3c0997 100644 --- a/quickstats/core/enums.py +++ b/quickstats/core/enums.py @@ -1,6 +1,8 @@ from typing import Any, Optional, Union, List, Dict from enum import Enum +__all__ = ["GeneralEnum", "DescriptiveEnum"] + class GeneralEnum(Enum): """ Extended Enum class with additional parsing and lookup functionalities. diff --git a/quickstats/core/methods.py b/quickstats/core/methods.py index e2eb9232c245dadfd806aa50d406c1308269ed40..dbe1497117f10cdbecca9d6b7dc33e5409be0340 100644 --- a/quickstats/core/methods.py +++ b/quickstats/core/methods.py @@ -1,4 +1,4 @@ -from typing import List, Union, Optional, Dict +from typing import List, Union, Optional, Dict, Callable import os import glob import json @@ -73,6 +73,9 @@ def get_root_version(): root_version = ROOTVersion((0, 0, 0)) return root_version +def is_root_installed(name:str=None): + return get_root_version() > (0, 0, 0) + def get_workspace_extensions(): extension_config = get_workspace_extension_config() extensions = extension_config['required'] @@ -162,5 +165,11 @@ def load_processor_methods(): for name, definition in BUILTIN_METHODS.items(): declare_expression(definition, name) -def module_exist(name:str): - return importlib.util.find_spec(name) is not None \ No newline at end of file +def module_exist(name: str) -> bool: + return importlib.util.find_spec(name) is not None + +def _require_module(name: str, fn:Optional[Callable]=None): + if fn is None: + fn = module_exist + if not fn(name): + raise ImportError(f"The module '{name}' is required but not found. Please install it to proceed.") \ No newline at end of file diff --git a/quickstats/interface/cppyy/__init__.py b/quickstats/interface/cppyy/__init__.py index 073e2c8dfb58afd0ecedc199da0d3532fcf0bb93..6792eac1ddba2c6c2af3136c71013a00263056c6 100644 --- a/quickstats/interface/cppyy/__init__.py +++ b/quickstats/interface/cppyy/__init__.py @@ -1,3 +1,6 @@ +import quickstats +quickstats.core.methods._require_module("cppyy") + from quickstats.interface.cppyy.core import * from quickstats.interface.cppyy.macros import load_macros, load_macro diff --git a/quickstats/interface/kerberos/__init__.py b/quickstats/interface/kerberos/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9ce5123d3912d081a80ed084bce050f69e807b91 --- /dev/null +++ b/quickstats/interface/kerberos/__init__.py @@ -0,0 +1,5 @@ +import quickstats + +from .core import * + +quickstats.methods._require_module("kerberos", is_kerberos_installed) \ No newline at end of file diff --git a/quickstats/interface/kerberos/core.py b/quickstats/interface/kerberos/core.py new file mode 100644 index 0000000000000000000000000000000000000000..a95fa9935d2aed5fdaee45793efef2b66a501ae3 --- /dev/null +++ b/quickstats/interface/kerberos/core.py @@ -0,0 +1,49 @@ +import os +import subprocess + +import quickstats + +__all__ = ["is_kerberos_installed", "get_kerberos_ticket_cache", + "kerberos_ticket_exists", "list_service_principals"] + +def is_kerberos_installed(name:str=None): + try: + subprocess.run(['klist', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + return True + except FileNotFoundError: + return False + except subprocess.CalledProcessError: + return True + except Exception as e: + return False + +def get_kerberos_ticket_cache(): + krb_cache = os.getenv('KRB5CCNAME') + if krb_cache: + return krb_cache + # Fallback to default path if KRB5CCNAME is not set + uid = os.getuid() + return f"/tmp/krb5cc_{uid}" + +def kerberos_ticket_exists(): + ticket_cache = get_kerberos_ticket_cache() + return os.path.exists(ticket_cache) + +def list_service_principals(): + list_service_principals = [] + try: + result = subprocess.run(['klist'], capture_output=True, text=True, check=True) + lines = result.stdout.split('\n') + for line in lines: + if '@' not in line: + continue + tokens = line.split() + if len(tokens) > 3 and '@' in tokens[-1]: + list_service_principals.append(tokens[-1]) + except subprocess.CalledProcessError: + quickstats.stdout.error("Failed to list tickets - are you sure Kerberos is configured correctly?") + except FileNotFoundError: + quickstats.stdout.error("Kerberos 'klist' command not found. Is Kerberos installed?") + except Exception as e: + quickstats.stdout.error(f"An unexpected error occurred: {e}") + return list_service_principals \ No newline at end of file diff --git a/quickstats/interface/root/TFile.py b/quickstats/interface/root/TFile.py index cd78b55e5d2367aac482b0c64b60c09ca77f1e0d..402016bec743ae8fcf46c6e433fe28651cafbdd9 100644 --- a/quickstats/interface/root/TFile.py +++ b/quickstats/interface/root/TFile.py @@ -7,11 +7,12 @@ import numpy as np from quickstats import semistaticmethod from quickstats.utils.path_utils import (resolve_paths, is_remote_path, remote_glob, - remote_isdir, remote_dirlist, dirlist, - local_file_exist, split_url) + remote_isdir, remote_listdir, listdir, + local_file_exist, split_url, + remote_file_exist) from quickstats.utils.root_utils import is_corrupt from quickstats.utils.common_utils import in_notebook -from quickstats.interface.xrootd import get_cachedir +from quickstats.utils.sys_utils import bytes_to_readable from .TObject import TObject class TFile(TObject): @@ -29,6 +30,39 @@ class TFile(TObject): def is_corrupt(f:Union["ROOT.TFile", str]): return is_corrupt(f) + @semistaticmethod + def _get_all_treenames(self, source:Union["ROOT.TFile", str]): + import ROOT + if isinstance(source, str): + source = ROOT.TFile.Open(source) + keys = [key.GetName() for key in source.GetListOfKeys()] + objs = [source.Get(key) for key in keys] + trees = [obj for obj in objs if isinstance(obj, ROOT.TTree)] + treenames = [tree.GetName() for tree in trees] + return treenames + + @semistaticmethod + def _get_main_treename(self, source:Union["ROOT.TFile", str]): + import ROOT + if isinstance(source, str): + source = ROOT.TFile.Open(source) + keys = [key.GetName() for key in source.GetListOfKeys()] + objs = [source.Get(key) for key in keys] + trees = [obj for obj in objs if isinstance(obj, ROOT.TTree)] + if not trees: + raise RuntimeError('no tree found in the root file') + elif len(trees) == 1: + return trees[0].GetName() + main_trees = [tree for tree in trees if tree.GetEntriesFast() > 1] + main_treenames = [tree.GetName() for tree in main_trees] + if not main_trees: + raise RuntimeError('no tree found with entries > 1') + elif len(main_trees) > 1: + raise RuntimeError('found multiple trees with entries > 1 : {names}'.format( + names=", ".join(main_treenames))) + return main_treenames[0] + + @semistaticmethod def _is_valid_filename(self, filename:str): return self.FILE_PATTERN.match(filename) is not None @@ -54,6 +88,7 @@ class TFile(TObject): strict_format:Optional[bool]=True, cached_only:bool=False): import ROOT + from quickstats.interface.xrootd import get_cachedir cachedir = get_cachedir() if cachedir is None: return list(paths) @@ -68,7 +103,7 @@ class TFile(TObject): cache_path = os.path.join(cachedir, filename) if os.path.exists(cache_path): if os.path.isdir(cache_path): - cache_paths = dirlist(cache_path) + cache_paths = listdir(cache_path) if strict_format: cache_paths = self._filter_valid_filenames(cache_paths) if not cache_paths: @@ -88,28 +123,30 @@ class TFile(TObject): resolve_cache:bool=False, expand_remote_files:bool=True, raise_on_error:bool=True): - remote_flag = True paths = resolve_paths(paths) filenames = [] + if resolve_cache: + paths = self._resolve_cached_remote_paths(paths) + # expand directories if necessary for path in paths: if is_remote_path(path): if local_file_exist(path): host, path = split_url(path) - else: - if remote_flag: - self.stdout.info("Resolving remote files. Network traffic overhead might be expected.") - remote_flag = False + elif remote_file_exist(path): if expand_remote_files and remote_isdir(path): - filenames.extend(remote_dirlist(path)) + filenames.extend(remote_listdir(path)) else: filenames.append(path) - continue - if os.path.isdir(path): - filenames.extend(dirlist(path)) - else: + else: + self.stdout.warning(f'Remote file "{path}" does not exist') + elif os.path.isdir(path): + filenames.extend(listdir(path)) + elif os.path.exists(path): filenames.append(path) + else: + self.stdout.warning(f'Local file "{path}" does not exist') if strict_format: filenames = self._filter_valid_filenames(filenames) if not filenames: @@ -117,8 +154,7 @@ class TFile(TObject): if resolve_cache: filenames = self._resolve_cached_remote_paths(filenames) import ROOT - invalid_filenames = [] - valid_filenames = [] + invalid_filenames, valid_filenames = [], [] for filename in filenames: if is_remote_path(filename): # delay the check of remote root file to when they are open @@ -138,8 +174,6 @@ class TFile(TObject): raise RuntimeError(f'Found empty/currupted file(s):\n{fmt_str}') else: self.stdout.warning(f'Found empty/currupted file(s):\n{fmt_str}') - if not remote_flag: - self.stdout.info("Finished resolving remote files.") return valid_filenames @staticmethod @@ -183,10 +217,39 @@ class TFile(TObject): return None return tree + def get_tree_compression_summary(self, treename:Optional[str]=None): + file = self.obj + if treename is None: + treename = self._deduce_treename(file) + tree = file.Get(treename) + total_bytes = tree.GetTotBytes() + zip_bytes = tree.GetZipBytes() + summary = { + 'total_bytes': total_bytes, + 'total_bytes_s': bytes_to_readable(total_bytes), + 'zip_bytes': zip_bytes, + 'zip_bytes_s': bytes_to_readable(zip_bytes), + 'comp_factor': total_bytes / zip_bytes + } + return summary + + def get_compression_summary(self, treenames:Optional[List[str]]=None): + file = self.obj + comp_setting = file.GetCompressionSettings() + summary = {} + summary["comp_setting"] = comp_setting + summary["trees"] = {} + if treenames is None: + treenames = self._get_all_treenames(file) + for treename in treenames: + summary["trees"][treename] = self.get_tree_compression_summary(treename) + return summary + @semistaticmethod - def fetch_remote_files(self, paths:Union[str, List[str]], + def copy_remote_files(self, paths:Union[str, List[str]], cache:bool=True, cachedir:str="/tmp", + parallel:bool=False, **kwargs): if isinstance(paths, str): paths = [paths] @@ -199,21 +262,30 @@ class TFile(TObject): self.stdout.warning(f"Remote file {path} can be accessed locally. Skipped.") continue remote_paths.append(path) - filenames = self.list_files(remote_paths, resolve_cache=cache, - expand_remote_files=True) + from quickstats.interface.xrootd import switch_cachedir + with switch_cachedir(cachedir): + filenames = self.list_files(remote_paths, resolve_cache=cache, + expand_remote_files=True) cached_files = [filename for filename in filenames if not is_remote_path(filename)] files_to_fetch = [filename for filename in filenames if is_remote_path(filename)] if cached_files: self.stdout.info(f'Cached remote file(s):\n' + '\n'.join(cached_files)) - from quickstats.interface.xrootd.utils import copy_files src, dst = [], [] for file in files_to_fetch: src.append(file) - dst.append(self._get_cache_path(file)) - if src: - self.stdout.info(f'Fetching remote file(s):\n' + '\n'.join(src)) - self.stdout.info(f'Destination(s):\n' + '\n'.join(dst)) - copy_files(src, dst, force=not cache, **kwargs) + dst.append(self._get_cache_path(file, cachedir=cachedir)) + if not src: + return None + from quickstats.interface.xrootd import XRDHelper + helper = XRDHelper(verbosity=self.stdout.verbosity) + if parallel: + helper.copy_files(src, dst, force=not cache, **kwargs) + return None + for src_i, dst_i in zip(src, dst): + helper.copy_files([src_i], [dst_i], force=not cache, **kwargs) + + def get(self, key:str): + return self.obj.Get(key) def close(self): self.obj.Close() diff --git a/quickstats/interface/root/__init__.py b/quickstats/interface/root/__init__.py index 345d2750888e4698777c0962e12e9aac77b0d619..773ff628e1af17dbfbdcb68da96cc68ac7fb5aef 100644 --- a/quickstats/interface/root/__init__.py +++ b/quickstats/interface/root/__init__.py @@ -1,5 +1,7 @@ import quickstats +quickstats.core.methods._require_module("ROOT", quickstats.core.methods.is_root_installed) + from .macros import load_macros, load_macro from .TObject import TObject from .TArrayData import TArrayData diff --git a/quickstats/interface/servicex/__init__.py b/quickstats/interface/servicex/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6ab1ed7c8f421b9763edc44b191211e6d544b39c --- /dev/null +++ b/quickstats/interface/servicex/__init__.py @@ -0,0 +1,5 @@ +import quickstats + +quickstats.core.methods._require_module("servicex") + +from .core import * \ No newline at end of file diff --git a/quickstats/interface/servicex/core.py b/quickstats/interface/servicex/core.py new file mode 100644 index 0000000000000000000000000000000000000000..400478cc0a7ffbbe831ccd320cfc26fda1117445 --- /dev/null +++ b/quickstats/interface/servicex/core.py @@ -0,0 +1,47 @@ +from typing import Optional +from functools import partial + +import httpx +from servicex.configuration import Configuration + +read_bak = Configuration.read +AsyncClient_bak = httpx.AsyncClient + +__all__ = ["set_cache_path", "set_async_client_timeout"] + +def set_cache_path(cache_path:Optional[str]=None): + def overwrite_read(cls, config_path: Optional[str] = None): + if config_path: + yaml_config = Configuration._add_from_path(Path(config_path), walk_up_tree=False) + else: + yaml_config = Configuration._add_from_path(walk_up_tree=True) + + if yaml_config: + yaml_config['cache_path'] = cache_path + return Configuration(**yaml_config) + else: + path_extra = f"in {config_path}" if config_path else "" + raise NameError( + "Can't find .servicex or servicex.yaml config file " + path_extra + ) + if cache_path is None: + Configuration.read = read_bak + else: + Configuration.read = classmethod(overwrite_read) + +def set_async_client_timeout(timeout:Optional[float]=None, + connect:Optional[float]=None, + read:Optional[float]=None, + write:Optional[float]=None, + pool:Optional[float]=None,): + timeout_spec = {} + if connect is not None: + timeout_spec['connect'] = connect + if read is not None: + timeout_spec['read'] = read + if write is not None: + timeout_spec['write'] = write + if pool is not None: + timeout_spec['pool'] = pool + timeout = httpx.Timeout(timeout, **timeout_spec) + httpx.AsyncClient = partial(AsyncClient_bak, timeout=timeout) \ No newline at end of file diff --git a/quickstats/interface/xrootd/__init__.py b/quickstats/interface/xrootd/__init__.py index fa049b6d52d13e277689f065d570d27794cf666c..c44608532b1b57d8bcd70461fe708eff26e1025c 100644 --- a/quickstats/interface/xrootd/__init__.py +++ b/quickstats/interface/xrootd/__init__.py @@ -1 +1,7 @@ -from .core import get_cachedir, set_cachedir, switch_cachedir \ No newline at end of file +import quickstats + +quickstats.core.methods._require_module("XRootD") + +from .core import * +from .filesystem import * +from .xrd_helper import XRDHelper \ No newline at end of file diff --git a/quickstats/interface/xrootd/core.py b/quickstats/interface/xrootd/core.py index 7c00bda193cd3f534b98fc9f518ccbf2ce2e4208..fe45ef739b9c00a5f566a1d8b654092ff0ba3ba7 100644 --- a/quickstats/interface/xrootd/core.py +++ b/quickstats/interface/xrootd/core.py @@ -1,5 +1,7 @@ from contextlib import contextmanager +__all__ = ["get_cachedir", "set_cachedir", "switch_cachedir"] + class Setting: CACHEDIR = None @@ -9,7 +11,6 @@ def get_cachedir(): def set_cachedir(dirname:str=None): Setting.CACHEDIR = dirname - @contextmanager def switch_cachedir(dirname:str): try: diff --git a/quickstats/interface/xrootd/filesystem.py b/quickstats/interface/xrootd/filesystem.py new file mode 100644 index 0000000000000000000000000000000000000000..6e9c84aedc49a1b90ca09b68833f2ab5fb13972e --- /dev/null +++ b/quickstats/interface/xrootd/filesystem.py @@ -0,0 +1,148 @@ +from typing import Optional, Union +import sys +if sys.version_info[0] > 2: + from urllib.parse import urlparse +else: + from urlparse import urlparse + +from quickstats import AbstractObject, timer +from XRootD.client import FileSystem as XRootDFileSystem +from XRootD.client.flags import StatInfoFlags +from XRootD.client import glob_funcs + +FILESYSTEMS = {} + +__all__ = ["FileSystem", "get_filesystem"] + +def split_url(url): + parsed_uri = urlparse(url) + domain = '{uri.scheme}://{uri.netloc}/'.format(uri=parsed_uri) + path = parsed_uri.path + if path.startswith("//"): + path = path[1:] + return domain, path + +class FileSystem(AbstractObject): + + def __init__(self, url:str, + verbosity:Optional[Union[int, str]]="INFO"): + super().__init__(verbosity=verbosity) + self.url = url + self.filesystem = XRootDFileSystem(url) + self.triggered = False + self.sanity_check() + + def __getattr__(self, name:str): + def method(*args, **kwargs): + return self._run_query(name, *args, **kwargs) + return method + + def _run_query(self, method:str, *args, **kwargs): + suppress_error = kwargs.pop("suppress_error", False) + if not hasattr(self.filesystem, method): + raise ValueError(f'XRootD FileSystem does not contain the method "{method}"') + if not self.triggered: + self.stdout.info(f'Initializing XRootD query to the server {self.url}. ' + f'Network traffic overhead might be expected.') + with timer() as t: + status, result = getattr(self.filesystem, method)(*args, **kwargs) + if not self.triggered: + self.stdout.info(f"Query completed in {t.interval:.2f}s") + self.triggered = True + if not suppress_error: + self._process_status(status, method) + return status, result + + def _process_status(self, status, name:str): + if status.error: + self.stdout.warning(f'Query "{name}" responded with error status. Message: {status.message}') + + def sanity_check(self): + if "root://" in self.url: + from quickstats.interface.kerberos import list_service_principals + sercice_principals = list_service_principals() + if not any("CERN.CH@CERN.CH" in principal for principal in sercice_principals): + self.stdout.warning("No kerberos ticket found for CERN.CH. " + "XRootD might not work properly. " + "Available kerberos service principals:") + self.stdout.warning("\n".join(sercice_principals), bare=True) + else: + self.stdout.info("Found valid kerberos ticket for CERN.CH.") + + def copy(self, source:str, target:str, force=False): + return self._run_query('copy', source, target, force=force) + + def listdir(self, path:str, timeout=0, **kwargs): + status, result = self._run_query('dirlist', path, timeout=timeout, **kwargs) + if status.error: + return [] + return [dir_.name for dir_ in result.dirlist] + + def stat(self, path:str, timeout=0, **kwargs): + return self._run_query('stat', path, timeout=timeout, **kwargs) + + def size(self, path:str, timeout=0, **kwargs): + status, result = self.stat(path, timeout=timeout, suppress_error=True) + if status.error: + return None + return result.size + + def exists(self, path:str, timeout=0): + status, result = self.stat(path, timeout=timeout, suppress_error=True) + return not status.error + + def isdir(self, path:str, timeout=0, **kwargs): + status, result = self.stat(path, timeout=timeout, suppress_error=True) + return (not status.error) and (result.flags & StatInfoFlags.IS_DIR) != 0 + + def isreadable(self, path:str, timeout=0, **kwargs): + status, result = self.stat(path, timeout=timeout, suppress_error=True) + return (not status.error) and (result.flags & StatInfoFlags.IS_READABLE) != 0 + + def iswritable(self, path:str, timeout=0, **kwargs): + status, result = self.stat(path, timeout=timeout, suppress_error=True) + return (not status.error) and (result.flags & StatInfoFlags.IS_WRITABLE) != 0 + + def glob(self, path:str, nourl:bool=True, **kwargs): + url = self.url + if not url.endswith('/'): + url += '/' + result = glob_funcs.glob(url + path) + if nourl: + return [p.replace(self.url, "").replace("//", "/") for p in result] + return result + + def ls(self, path:str, nourl:bool=True, **kwargs): + if "*" in path: + return self.glob(path, nourl=nourl, **kwargs) + timeout = kwargs.get("timeout", 0) + status, result = self.stat(path, timeout=timeout) + if status.error: + return [] + if result.flags & StatInfoFlags.IS_DIR: + return self.listdir(path, **kwargs) + return [path] + + def mv(self, source:str, dest:str, timeout=0, **kwargs): + return self._run_query('mv', source, dest, timeout=timeout, **kwargs) + + def rm(self, path:str, timeout=0, **kwargs): + return self._run_query('rm', path, timeout=timeout, **kwargs) + + def mkdir(self, path:str, timeout=0, **kwargs): + return self._run_query('mkdir', path, timeout=timeout, **kwargs) + + def rmdir(self, path:str, timeout=0, **kwargs): + return self._run_query('rmdir', path, timeout=timeout, **kwargs) + +def get_filesystem(url:str): + """ + Parameters: url (string) – The URL of the server to connect with + """ + url = url.rstrip("/") + if url in FILESYSTEMS: + return FILESYSTEMS[url] + filesystem = FileSystem(url) + # caching the filesystem instance + FILESYSTEMS[url] = filesystem + return filesystem \ No newline at end of file diff --git a/quickstats/interface/xrootd/path.py b/quickstats/interface/xrootd/path.py new file mode 100644 index 0000000000000000000000000000000000000000..ebf390d0c42ef927bffbea30c2aef4becfe19c9f --- /dev/null +++ b/quickstats/interface/xrootd/path.py @@ -0,0 +1,35 @@ +from .filesystem import split_url, get_filesystem + +def _call_path_method(method:str, path:str, **kwargs): + domain, path = split_url(path) + filesystem = get_filesystem(domain) + if not hasattr(filesystem, method): + raise ValueError(f'not implemented method: {method}') + return getattr(filesystem, method)(path, **kwargs) + +def listdir(path:str, **kwargs): + return _call_path_method('listdir', path, **kwargs) + +def mkdir(path:str, **kwargs): + return _call_path_method('mkdir', path, **kwargs) + +def ls(path:str, nourl:bool=False, **kwargs): + return _call_path_method('ls', path, nourl=nourl, **kwargs) + +def rmdir(path:str, **kwargs): + return _call_path_method('rmdir', path, **kwargs) + +def rm(path:str, **kwargs): + return _call_path_method('rm', path, **kwargs) + +def isdir(path:str, **kwargs): + return _call_path_method('isdir', path, **kwargs) + +def exists(path:str, **kwargs): + return _call_path_method('exists', path, **kwargs) + +def glob(path:str, nourl:bool=False, **kwargs): + return _call_path_method('glob', path, nourl=nourl, **kwargs) + +def stat(path:str, **kwargs): + return _call_path_method('stat', path, **kwargs) \ No newline at end of file diff --git a/quickstats/interface/xrootd/utils.py b/quickstats/interface/xrootd/utils.py deleted file mode 100644 index a907076855c414087990f71529809b0937cc8e44..0000000000000000000000000000000000000000 --- a/quickstats/interface/xrootd/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import List -from XRootD.client import CopyProcess - -#https://xrootd.slac.stanford.edu/doc/python/xrootd-python-0.1.0/modules/client/copyprocess.html -def copy_files(src:List[str], dst:List[str], force:bool=False, **kwargs): - copy_process = CopyProcess() - for src_i, dst_i in zip(src, dst): - copy_process.add_job(src_i, dst_i, force=force, **kwargs) - copy_process.prepare() - copy_process.run() \ No newline at end of file diff --git a/quickstats/interface/xrootd/xrd_helper.py b/quickstats/interface/xrootd/xrd_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..89d5906d71c93462f3378bf017f8b11bbd4b73a9 --- /dev/null +++ b/quickstats/interface/xrootd/xrd_helper.py @@ -0,0 +1,61 @@ +import os +from typing import List, Optional, Union +from XRootD.client import CopyProcess + +from quickstats import AbstractObject, semistaticmethod, timer + +class XRDHelper(AbstractObject): + + def __init__(self, verbosity:Optional[Union[int, str]]="INFO"): + super().__init__(verbosity=verbosity) + + @staticmethod + def get_nbytes(paths:List[str]): + pass + + #https://xrootd.slac.stanford.edu/doc/python/xrootd-python-0.1.0/modules/client/copyprocess.html + @semistaticmethod + def copy_files(self, src:List[str], dst:List[str], force:bool=False, **kwargs): + self.stdout.info(f'Copying remote file(s):\n' + '\n'.join(src)) + self.stdout.info(f'Destination(s):\n' + '\n'.join(dst)) + with timer() as t: + copy_process = CopyProcess() + for src_i, dst_i in zip(src, dst): + copy_process.add_job(src_i, dst_i, force=force, **kwargs) + copy_process.prepare() + copy_process.run() + self.stdout.info(f"Copy finished. Total time taken: {t.interval}.") + + @semistaticmethod + def copy_file_cli(self, src:str, dst:str, recursive:bool=False, + force:bool=False, allow_http:bool=False, pbar:bool=True, + retry:Optional[int]=None, silent:bool=False): + options = [] + if allow_http: + options.append("--allow-http") + if force: + options.append("--force") + if not pbar: + options.append("--nopbar") + if recursive: + options.append("--recursive") + if retry is not None: + options.append(f"--retry {retry}") + if silent: + options.append("--silent") + options = " ".join(options) + cmd = f"xrdcp {options} {src} {dst}" + os.system(cmd) + + @semistaticmethod + def copy_files_cli(self, src:List[str], dst:List[str], recursive:bool=False, + force:bool=False, allow_http:bool=False, pbar:bool=True, + retry:Optional[int]=None, silent:bool=False): + self.stdout.info(f'Copying remote file(s):\n' + '\n'.join(src)) + self.stdout.info(f'Destination(s):\n' + '\n'.join(dst)) + with timer() as t: + for src_i, dst_i in zip(src, dst): + self.copy_file_cli(src_i, dst_i, recursive=recursive, + force=force, allow_http=allow_http, + pbar=pbar, retry=retry, silent=silent) + self.stdout.info(f"Copy finished. Total time taken: {t.interval}.") \ No newline at end of file diff --git a/quickstats/maths/numerics.py b/quickstats/maths/numerics.py index 7c07c5c4052338b33afa0007a829c714ca37ff44..0cfb0f377cc9fc5d5337b6527bdc058c5567f58b 100644 --- a/quickstats/maths/numerics.py +++ b/quickstats/maths/numerics.py @@ -1,4 +1,4 @@ -from typing import Union, Any, List, Dict, Optional, Tuple +from typing import Union, Any, List, Dict, Optional, Tuple, Callable from fractions import Fraction import decimal @@ -202,4 +202,55 @@ def cartesian_product(*arrays): arr = np.empty([len(a) for a in arrays] + [la], dtype=dtype) for i, a in enumerate(np.ix_(*arrays)): arr[...,i] = a - return arr.reshape(-1, la) \ No newline at end of file + return arr.reshape(-1, la) + +def get_mask(x, conditions:List[Union[Tuple[float, float], Callable]]=None): + mask = np.full(x.shape, False) + for condition in conditions: + if isinstance(condition, (tuple, list)): + xmin, xmax = condition + mask |= ((x > xmin) & (x < xmax)) + else: + mask |= np.array(list(map(condition, x))) + return mask + +def get_subsequences(arr, mask, min_length=1): + """ + Finds and returns continuous subsequences of an array where the mask is True. + + Parameters: + - arr (np.array): The array from which to extract subsequences. + - mask (np.array): A boolean array where True indicates the elements of `arr` to consider for forming subsequences. + - min_length (int): The minimum length of the subsequence to be returned. Default is 2. + + Returns: + - list of np.array: A list containing the subsequences from `arr` that meet the criteria of continuous True values in `mask` and are at least `min_length` elements long. + + Example: + >>> arr = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + >>> mask = np.array([False, True, True, False, False, True, True, True, False, True]) + >>> get_subsequences(arr, mask, min_length=3) + [array([6, 7, 8])] + """ + + # Ensure mask is a boolean array + mask = np.asarray(mask, dtype=bool) + + # Calculate changes in the mask + changes = np.diff(mask.astype(int)) + # Identify where sequences start (False to True transition) + start_indices = np.where(changes == 1)[0] + 1 + # Identify where sequences end (True to False transition) + end_indices = np.where(changes == -1)[0] + 1 + + # Handle case where mask starts with True + if mask[0]: + start_indices = np.insert(start_indices, 0, 0) + # Handle case where mask ends with True + if mask[-1]: + end_indices = np.append(end_indices, len(mask)) + + # Gather and return sequences that meet the minimum length requirement + sequences = [arr[start:end] for start, end in zip(start_indices, end_indices) if end - start >= min_length] + + return sequences \ No newline at end of file diff --git a/quickstats/maths/statistics.py b/quickstats/maths/statistics.py index 7a24a653ce46a19ffdbc5143ee20f3386596fdc4..cdb2d052f9179c6d727b5d53202bb8b6f6f87262 100644 --- a/quickstats/maths/statistics.py +++ b/quickstats/maths/statistics.py @@ -1,4 +1,4 @@ -from typing import Union, Optional, List, Dict, Tuple, Sequence +from typing import Union, Optional, List, Dict, Tuple, Sequence, Callable import math import numpy as np @@ -515,8 +515,6 @@ def histogram2d(x:np.ndarray, y:np.ndarray, unit_weight = np.allclose(weights, np.ones(weights.shape)) error_option = BinErrorOption.POISSON if unit_weight else BinErrorOption.SUMW2 if error_option == BinErrorOption.POISSON: - from pdb import set_trace - set_trace() pois_interval = get_poisson_interval(bin_content.flatten()) bin_errors = (pois_interval["lo"].reshape(bin_content.shape), pois_interval["hi"].reshape(bin_content.shape)) @@ -621,6 +619,7 @@ def get_hist_data(x:np.ndarray, weights:Optional[np.ndarray]=None, } return hist_data + def get_stacked_hist_data(x:List[np.ndarray], weights:List[Optional[np.ndarray]]=None, bins:Union[int, Sequence]=10, @@ -654,12 +653,7 @@ def get_stacked_hist_data(x:List[np.ndarray], error_option=error_option) return hist_data else: - stacked_hist_data = { - "x": [], - "y": [], - "xerr": [], - "yerr": [] - } + hist_data_list = [] if weights is None: weights = len(x) * None for x_i, weights_i in zip(x, weights): @@ -672,18 +666,29 @@ def get_stacked_hist_data(x:List[np.ndarray], clip_weight=clip_weight, xerr=xerr, yerr=yerr, error_option=error_option) - stacked_hist_data['x'].append(hist_data['x']) - stacked_hist_data['y'].append(hist_data['y']) - stacked_hist_data['xerr'].append(hist_data['xerr']) - stacked_hist_data['yerr'].append(hist_data['yerr']) + hist_data_list.append(hist_data) if normalize: - norm_factor = np.sum(stacked_hist_data['y']) - stacked_hist_data['y'] = [y / norm_factor for y in stacked_hist_data['y']] + norm_factor = np.sum([data['y'] for data in hist_data_list]) + for data in hist_data_list: + data['y'] = data['y'] / norm_factor + if isinstance(data['yerr'], tuple): + data['yerr'] = (data['yerr'][0] / norm_factor, + data['yerr'][1] / norm_factor) + elif data['yerr'] is not None: + data['yerr'] = data['yerr'] / norm_factor if divide_bin_width: bin_edges = np.histogram_bin_edges([bin_range[0], bin_range[1]], bins=bins, range=bin_range) bin_widths = bin_edge_to_bin_width(bin_edges) - stacked_hist_data['y'] = [y / bin_widths for y in stacked_hist_data['y']] + for data in hist_data_list: + data['y'] = data['y'] / bin_widths + if isinstance(data['yerr'], tuple): + data['yerr'] = (data['yerr'][0] / bin_widths, + data['yerr'][1] / bin_widths) + elif data['yerr'] is not None: + data['yerr'] = data['yerr'] / bin_widths + from quickstats.utils.common_utils import list_of_dict_to_dict_of_list + stacked_hist_data = list_of_dict_to_dict_of_list(hist_data_list) return stacked_hist_data def get_sumw2(weights:np.ndarray): @@ -731,6 +736,17 @@ def get_bin_centers_from_range(xlow:float, xhigh:float, nbins:int, bin_precision bins = np.around(np.linspace(low_bin_center, high_bin_center, nbins), bin_precision) return bins +def select_binned_data(mask, x, y, xerr=None, yerr=None): + x, y = x[mask], y[mask] + def select_err(err, mask_): + if (err is None) or (not isinstance(err, (list, tuple, np.ndarray))): + return err + if isinstance(err, tuple): + return (select_err(err[0], mask_), select_err(err[1], mask_)) + return err[mask_] + xerr, yerr = select_err(xerr, mask), select_err(yerr, mask) + return x, y, xerr, yerr + def pvalue_to_significance(pvalue:float): import ROOT significance = ROOT.RooStats.PValueToSignificance(pvalue) diff --git a/quickstats/plots/abstract_plot.py b/quickstats/plots/abstract_plot.py index d03d78162a9cec3d153d379974b0e88812068a43..ba6bc3cae10a55036799842441e3f36dcd1e5aed 100644 --- a/quickstats/plots/abstract_plot.py +++ b/quickstats/plots/abstract_plot.py @@ -8,12 +8,15 @@ import matplotlib from quickstats import AbstractObject, semistaticmethod from quickstats.plots import get_color_cycle, get_cmap from quickstats.plots.color_schemes import QUICKSTATS_PALETTES -from quickstats.plots.template import (single_frame, parse_styles, format_axis_ticks, +from quickstats.plots.template import (single_frame, ratio_frame, + parse_styles, format_axis_ticks, parse_analysis_label_options, centralize_axis, - create_transform, draw_multiline_text) + create_transform, draw_multiline_text, + CUSTOM_HANDLER_MAP) from quickstats.utils.common_utils import combine_dict, insert_periodic_substr -from quickstats.maths.statistics import bin_center_to_bin_edge, get_hist_comparison_data +from quickstats.maths.statistics import bin_center_to_bin_edge, get_hist_comparison_data, select_binned_data from quickstats.maths.statistics import HistComparisonMode +from quickstats.maths.numerics import get_mask, get_subsequences from .core import PlotFormat, ErrorDisplayFormat class AbstractPlot(AbstractObject): @@ -112,6 +115,8 @@ class AbstractPlot(AbstractObject): return self.resolve_handle_label(handle[0]) elif isinstance(handle, tuple): _, label = self.resolve_handle_label(handle[0]) + handle = tuple([h[0] if (isinstance(h, list) and len(h) == 1) \ + else h for h in handle]) elif hasattr(handle, 'get_label'): label = handle.get_label() else: @@ -180,8 +185,10 @@ class AbstractPlot(AbstractObject): labels.append(label) return handles, labels - def draw_frame(self, frame_method:Callable=None, **kwargs): - if frame_method is None: + def draw_frame(self, ratio:bool=False, **kwargs): + if ratio: + frame_method = ratio_frame + else: frame_method = single_frame ax = frame_method(styles=self.styles, prop_cycle=get_color_cycle(self.cmap), @@ -319,6 +326,153 @@ class AbstractPlot(AbstractObject): elif mode == HistComparisonMode.DIFFERENCE: ylabel = "Difference" self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel) + + + if plot_format == PlotFormat.HIST: + # draw data + hist_y, _, handle = ax.hist(hist_data['x'], bins, range=bin_range, + weights=hist_data['y'], **styles) + assert np.allclose(hist_data['y'], hist_y) + # draw error only + handles = self.draw_binned_data(ax, hist_data, + bin_edges=bin_edges, + draw_data=False, + draw_error=target_show_error, + error_format=error_format, + error_styles=error_styles) + if not isinstance(handle, list): + handle = [handle] + handles = tuple(list(handles) + handle) + elif plot_format == PlotFormat.ERRORBAR: + handles = self.draw_binned_data(ax, hist_data, + bin_edges=bin_edges, + styles=styles, + draw_error=target_show_error, + error_format=error_format, + error_styles=error_styles) + + def _draw_hist_from_binned_data(self, ax, x, y, + xerr=None, yerr=None, + bin_edges:Optional[np.ndarray]=None, + hide:Optional[Union[Tuple[float, float], Callable]]=None, + styles:Optional[Dict]=None): + styles = combine_dict(self.styles['hist'], styles) + # assume uniform binning + if bin_edges is None: + bin_center = np.array(x) + bin_edges = bin_center_to_bin_edge(bin_center) + bins, range = bin_edges, (bin_edges[0], bin_edges[1]) + if hide is not None: + mask = get_mask(x, [hide]) + y[mask] = 0. + hist_y, _, handle = ax.hist(x, bins, range=range, + weights=y, **styles) + assert np.allclose(y, hist_y) + return handle + + def _draw_stacked_hist_from_binned_data(self, ax, x_list, y_list, + bin_edges:Optional[np.ndarray]=None, + hide_list:Optional[List[Union[Tuple[float, float], Callable]]]=None, + styles:Optional[Dict]=None): + styles = combine_dict(self.styles['hist'], styles) + if bin_edges is None: + bin_edges_list = [] + for x in x_list: + bin_center = np.array(x) + bin_edges = bin_center_to_bin_edge(bin_center) + bin_edges_list.append(bin_edges) + + if not all(np.array_equal(bin_edges, bin_edges_list[0]) \ + for bin_edges in bin_edges_list): + raise RuntimeError('subhistograms in a stacked histogram have different binnings') + bin_edges = bin_edges_list[0] + bins, range = bin_edges, (bin_edges[0], bin_edges[1]) + if hide_list is not None: + assert len(hide_list) == len(x_list) + for i, hide in enumerate(hide_list): + if hide is None: + continue + mask = get_mask(x_list[i], [hide]) + y_list[i][mask] = 0. + hist_y, _, handles = ax.hist(x_list, bins, range=range, + weights=y_list, stacked=True, + **styles) + #assert ... + return hist_y, handles + + def _draw_errorbar(self, ax, x, y, + xerr=None, yerr=None, + hide:Optional[Union[Tuple[float, float], Callable]]=None, + styles:Optional[Dict]=None): + styles = combine_dict(self.styles['errorbar'], styles) + if hide is not None: + mask = ~get_mask(x, [hide]) + x, y, xerr, yerr = select_binned_data(mask, x, y, xerr, yerr) + handle = ax.errorbar(x, y, xerr=xerr, yerr=yerr, **styles) + return handle + + def _draw_fill_from_binned_data(self, ax, x, y, + xerr=None, yerr=None, + bin_edges:Optional[np.ndarray]=None, + hide:Optional[Union[Tuple[float, float], Callable]]=None, + styles:Optional[Dict]=None): + styles = combine_dict(self.styles['fill_between'], styles) + # assume uniform binning + if bin_edges is None: + bin_center = np.array(x) + bin_edges = bin_center_to_bin_edge(bin_center) + if yerr is None: + yerr = 0. + indices = np.arange(len(x)) + if hide is not None: + mask = ~get_mask(x, [hide]) + sections_indices = get_subsequences(indices, mask, min_length=2) + else: + sections_indices = [indices] + handles = [] + for section_indices in sections_indices: + mask = np.full(x.shape, False) + mask[section_indices] = True + x_i, y_i, xerr_i, yerr_i = select_binned_data(mask, x, y, xerr, yerr) + # extend to edge + x_i[0] = bin_edges[section_indices[0]] + x_i[-1] = bin_edges[section_indices[-1] + 1] + if isinstance(yerr_i, tuple): + yerrlo = y_i - yerr_i[0] + yerrhi = y_i + yerr_i[1] + else: + yerrlo = y_i - yerr_i + yerrhi = y_i + yerr_i + handle = ax.fill_between(x_i, yerrlo, yerrhi, **styles) + handles.append(handle) + return handles[0] + + def _draw_shade_from_binned_data(self, ax, x, y, + xerr=None, yerr=None, + bin_edges:Optional[np.ndarray]=None, + hide:Optional[Union[Tuple[float, float], Callable]]=None, + styles:Optional[Dict]=None): + styles = combine_dict(styles) + # assume uniform binning + if bin_edges is None: + bin_center = np.array(x) + bin_edges = bin_center_to_bin_edge(bin_center) + bin_widths = np.diff(bin_edges) + if yerr is None: + yerr = 0. + if hide is not None: + mask = ~get_mask(x, [hide]) + x, y, xerr, yerr = select_binned_data(mask, x, y, xerr, yerr) + bin_widths = bin_widths[mask] + if isinstance(yerr, tuple): + height = yerr[0] + yerr[1] + bottom = y - yerr[0] + else: + height = 2 * yerr + bottom = y - yerr + handle = ax.bar(x=x, height=height, bottom=bottom, + width=bin_widths, **styles) + return handle def draw_binned_data(self, ax, data, draw_data:bool=True, @@ -327,12 +481,15 @@ class AbstractPlot(AbstractObject): plot_format:Union[PlotFormat, str]='errorbar', error_format:Union[ErrorDisplayFormat, str]='errorbar', styles:Optional[Dict]=None, + hide:Optional[Union[Tuple[float, float], Callable]]=None, error_styles:Optional[Dict]=None): + if (not draw_data) and (not draw_error): + raise ValueError('can not draw nothing') if styles is None: styles = {} if error_styles is None: error_styles = {} - plot_format = PlotFormat.parse(plot_format) + plot_format = PlotFormat.parse(plot_format) error_format = ErrorDisplayFormat.parse(error_format) handle, error_handle = None, None @@ -340,36 +497,125 @@ class AbstractPlot(AbstractObject): xerr, yerr = data.get('xerr', 0), data.get('yerr', 0) if draw_data: - styles = combine_dict(self.styles['errorbar'], styles) - if plot_format == PlotFormat.ERRORBAR: + if plot_format == PlotFormat.HIST: + handle = self._draw_hist_from_binned_data(ax, x, y, + bin_edges=bin_edges, + hide=hide, + styles=styles) + elif plot_format == PlotFormat.ERRORBAR: if (not draw_error) or (error_format != ErrorDisplayFormat.ERRORBAR): - handle = ax.errorbar(x, y, **styles) + handle = self._draw_errorbar(ax, x, y, + hide=hide, + styles=styles) else: - handle = ax.errorbar(**data, **styles) + handle = self._draw_errorbar(ax, x, y, + xerr=xerr, yerr=yerr, + hide=hide, + styles=styles) else: raise RuntimeError(f'unsupported plot format: {plot_format.name}') if draw_error: if error_format == ErrorDisplayFormat.FILL: - if isinstance(yerr, tuple): - error_handle = ax.fill_between(x, y - yerr[0], y + yerr[1], - **error_styles, zorder=-1) - else: - error_handle = ax.fill_between(x, y - yerr, y + yerr, - **error_styles, zorder=-1) + error_handle = self._draw_fill_from_binned_data(ax, x, y, yerr=yerr, + hide=hide, + styles={**error_styles, + "zorder": -1}) elif error_format == ErrorDisplayFormat.SHADE: - if bin_edges is None: - bin_edges = bin_center_to_bin_edge(x) - bin_widths = np.diff(bin_edges) - if isinstance(yerr, tuple): - error_handle = ax.bar(x=x, height=yerr[0] + yerr[1], - bottom=y - yerr[0], width=bin_widths, - **error_styles, zorder=-1) - else: - error_handle = ax.bar(x=x, height=2*yerr, - bottom=y - yerr, width=bin_widths, - **error_styles, zorder=-1) - elif error_format == ErrorDisplayFormat.ERRORBAR: - error_handle = ax.errorbar(**data, **error_styles) + error_handle = self._draw_shade_from_binned_data(ax, x, y, yerr=yerr, + bin_edges=bin_edges, + hide=hide, + styles={**error_styles, + "zorder": -1}) + elif ((error_format == ErrorDisplayFormat.ERRORBAR) and + ((not draw_data) or (plot_format !=PlotFormat.ERRORBAR))): + error_handle = self._draw_errorbar(ax, x, y, + xerr=xerr, yerr=yerr, + hide=hide, + styles={**error_styles, + "marker": 'none'}) + if isinstance(handle, list): + handle = handle[0] handles = tuple([h for h in [handle, error_handle] if h is not None]) - return handles \ No newline at end of file + return handles + + def draw_stacked_binned_data(self, ax, data, + draw_data:bool=True, + draw_error:bool=True, + bin_edges:Optional[np.ndarray]=None, + plot_format:Union[PlotFormat, str]='errorbar', + error_format_list:Union[ErrorDisplayFormat, str]='errorbar', + styles:Optional[Dict]=None, + hide_list:Optional[Union[Tuple[float, float], Callable]]=None, + error_styles_list:Optional[Dict]=None): + if (not draw_data) and (not draw_error): + raise ValueError('can not draw nothing') + n_component = len(data['x']) + if styles is None: + styles = {} + if error_styles_list is None: + error_styles_list = [{}] * n_component + plot_format = PlotFormat.parse(plot_format) + error_format_list = [ErrorDisplayFormat.parse(fmt) for fmt in error_format_list] + handles, error_handles = None, None + + x_list, y_list = data['x'], data['y'] + xerr_list, yerr_list = data.get('xerr', None), data.get('yerr', None) + if draw_data: + if plot_format == PlotFormat.HIST: + plot_func = self._draw_stacked_hist_from_binned_data + hist_y, handles = plot_func(ax, x_list, y_list, + bin_edges=bin_edges, + hide_list=hide_list, + styles=styles) + else: + raise RuntimeError(f'unsupported format for stacked plot: {plot_format.name}') + if draw_error: + error_handles = [] + def get_component(obj, index): + if obj is not None: + return obj[index] + return None + for i in range(n_component): + error_format = error_format_list[i] + x, y = x_list[i], hist_y[i] + xerr = get_component(xerr_list, i) + yerr = get_component(yerr_list, i) + hide = get_component(hide_list, i) + error_styles = get_component(error_styles_list, i) + if error_format == ErrorDisplayFormat.FILL: + error_handle = self._draw_fill_from_binned_data(ax, x, y, yerr=yerr, + hide=hide, + styles={**error_styles, + "zorder": -1}) + elif error_format == ErrorDisplayFormat.SHADE: + error_handle = self._draw_shade_from_binned_data(ax, x, y, yerr=yerr, + bin_edges=bin_edges, + hide=hide, + styles={**error_styles, + "zorder": -1}) + elif error_format == ErrorDisplayFormat.ERRORBAR: + error_handle = self._draw_errorbar(ax, x, y, + xerr=xerr, yerr=yerr, + hide=hide, + styles={**error_styles, + "marker": 'none'}) + error_handles.append(error_handle) + if error_handles is None: + return handles + if handles is None: + return error_handles + handles = [(handle, error_handle) for handle, error_handle in zip(handles, error_handles)] + return handles + + def draw_legend(self, ax, handles=None, labels=None, + handler_map=None, **kwargs): + if (handles is None) and (labels is None): + handles, labels = self.get_legend_handles_labels() + if handler_map is not None: + handler_map = {**CUSTOM_HANDLER_MAP, **handler_map} + else: + handler_map = CUSTOM_HANDLER_MAP + styles = {**self.styles['legend'], **kwargs} + styles['handler_map'] = handler_map + ax.legend(handles, labels, **styles) \ No newline at end of file diff --git a/quickstats/plots/bidirectional_bar_chart.py b/quickstats/plots/bidirectional_bar_chart.py index 51033904b2722f348ea66538bb375b8b321f4bce..ee53a887d3caba521726e4bedea37b0f3aebc548 100644 --- a/quickstats/plots/bidirectional_bar_chart.py +++ b/quickstats/plots/bidirectional_bar_chart.py @@ -264,7 +264,7 @@ class BidirectionalBarChart(AbstractPlot): secondary_styles['color'] = 'k' target_handles = [lines.Line2D([0], [0], **primary_styles), lines.Line2D([0], [0], **secondary_styles)] - ax.legend(target_handles, target_labels, **self.styles['legend']) + self.draw_legend(ax, handles=target_handles, labels=target_labels) ax.add_artist(legend_updown) """ # legend for targets diff --git a/quickstats/plots/general_1D_plot.py b/quickstats/plots/general_1D_plot.py index c886ed921237f16872153dc64364133ccf7b9965..2633d6dc167758de1254f06606057a8784d5bd4c 100644 --- a/quickstats/plots/general_1D_plot.py +++ b/quickstats/plots/general_1D_plot.py @@ -167,8 +167,7 @@ class General1DPlot(AbstractPlot): raise ValueError("invalid data format") self.legend_order = legend_order - handles, labels = self.get_legend_handles_labels() - ax.legend(handles, labels, **self.styles['legend']) + self.draw_legend(ax) self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel) self.set_axis_range(ax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, ypad=ypad) diff --git a/quickstats/plots/general_2D_plot.py b/quickstats/plots/general_2D_plot.py index ea6799c31f247a36bce4e6ee8cd2efd7853de3c9..d1ca2c4a786a3db174fd46d65aae291826c0e068 100644 --- a/quickstats/plots/general_2D_plot.py +++ b/quickstats/plots/general_2D_plot.py @@ -100,11 +100,12 @@ class General2DPlot(AbstractPlot): if draw_clabel: ax.clabel(handle, **self.styles['clabel']) + if draw_contourf: handle = ax.contourf(X, Y, Z, levels=contour_levels, **self.styles['contourf']) + if draw_scatter: handle = ax.scatter(x, y, **self.styles['scatter']) - ax.legend(**self.styles['legend']) self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel, title=title) diff --git a/quickstats/plots/general_distribution_plot.py b/quickstats/plots/general_distribution_plot.py index 6e348daf9fc47c79bfd5a820af40a5089ce609ee..e55493de6f0e3d34cf7b5950eaf0f055f3003168 100644 --- a/quickstats/plots/general_distribution_plot.py +++ b/quickstats/plots/general_distribution_plot.py @@ -9,7 +9,7 @@ from quickstast import semistaticmethod from quickstats.plots.color_schemes import QUICKSTATS_PALETTES from quickstats.plots import AbstractPlot, CollectiveDataPlot -from quickstats.plots.template import suggest_markersize, ratio_frames, centralize_axis, create_transform +from quickstats.plots.template import suggest_markersize, centralize_axis, create_transform from quickstats.utils.common_utils import combine_dict from quickstats import GeneralEnum @@ -38,7 +38,7 @@ class GeneralDistributionPlot(AbstractPlot): "legend": { "borderpad": 1 }, - "ratio_frames": { + "ratio_frame": { "height_ratios": (4, 1) } } @@ -384,10 +384,9 @@ class GeneralDistributionPlot(AbstractPlot): ypad:Optional[float]=None): if comparison_options is not None: - ax, ax_ratio = self.draw_frame(ratio_frames, logx=logx, logy=logy, - **self.styles["ratio_frames"]) + ax, ax_ratio = self.draw_frame(ratio=True, logx=logx, logy=logy) else: - ax = self.draw_frame(logx=logx, logy=logy) + ax = self.draw_frame(ratio=False, logx=logx, logy=logy) for name in self.collective_data: if (targets is not None) and(name not in targets): @@ -426,9 +425,8 @@ class GeneralDistributionPlot(AbstractPlot): self.colors[name] = handle[0].get_color() self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel) - self.set_axis_range(ax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, ypad=ypad) - handles, labels = self.get_legend_handles_labels() - ax.legend(handles, labels, **self.styles['legend']) + self.set_axis_range(ax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, ypad=ypad) + self.draw_legend(ax) if comparison_options is not None: if not isinstance(comparison_options, list): diff --git a/quickstats/plots/hypotest_inverter_plot.py b/quickstats/plots/hypotest_inverter_plot.py index a8123ec5b49c18755786a41ce63c70b34ed531d9..faa0598d88474969f868338ff939f2eaef1879b3 100644 --- a/quickstats/plots/hypotest_inverter_plot.py +++ b/quickstats/plots/hypotest_inverter_plot.py @@ -150,5 +150,5 @@ class HypoTestInverterPlot(AbstractPlot): handles[2].set_linewidth(1.0) handles = handles[3:] + [handles[0], handles[1], (handles[2], border_leg)] labels = labels[3:] + [labels[0], labels[1], labels[2]] - ax.legend(handles, labels, loc='upper right', frameon=False, **self.styles['legend']) + self.draw_legend(ax, handles, labels, loc=loc, frameon=frameon) return ax \ No newline at end of file diff --git a/quickstats/plots/likelihood_2D_plot.py b/quickstats/plots/likelihood_2D_plot.py index f9635948f796caffab329c3c7cbeef13775b08ec..db4e61097462b9ef35d31fddbe047c63d4de6935 100644 --- a/quickstats/plots/likelihood_2D_plot.py +++ b/quickstats/plots/likelihood_2D_plot.py @@ -363,8 +363,7 @@ class Likelihood2DPlot(AbstractPlot): **self.config['sm_line_styles']) if draw_legend: - handles, labels = self.get_legend_handles_labels() - ax.legend(handles, labels, **self.styles['legend']) + self.draw_legend(ax) self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel) self.set_axis_range(ax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax) diff --git a/quickstats/plots/likelihood_scan_plot.py b/quickstats/plots/likelihood_scan_plot.py deleted file mode 100644 index 652b5b3c84d158ff708eecee3e0edc054463b7ea..0000000000000000000000000000000000000000 --- a/quickstats/plots/likelihood_scan_plot.py +++ /dev/null @@ -1,13 +0,0 @@ - - -class LikelihoodScanPlot(object): - def __init__(self): - self._data = None - - @property - def data(self): - return self._data - - def load(self, input_files): - pass - \ No newline at end of file diff --git a/quickstats/plots/pdf_distribution_plot.py b/quickstats/plots/pdf_distribution_plot.py index 0d56b32898ae75b13c256d561b457447cfa16665..d79963745aea9a440ffc8fa6ada28d8d148ae050 100644 --- a/quickstats/plots/pdf_distribution_plot.py +++ b/quickstats/plots/pdf_distribution_plot.py @@ -7,7 +7,7 @@ import numpy as np from quickstats.plots.color_schemes import QUICKSTATS_PALETTES from quickstats.plots import AbstractPlot -from quickstats.plots.template import suggest_markersize, ratio_frames, centralize_axis, create_transform +from quickstats.plots.template import suggest_markersize, centralize_axis, create_transform from quickstats.utils.common_utils import combine_dict from quickstats import GeneralEnum @@ -37,7 +37,7 @@ class PdfDistributionPlot(AbstractPlot): "legend": { "borderpad": 1 }, - "ratio_frames": { + "ratio_frame": { "height_ratios": (4, 1) } } @@ -409,10 +409,9 @@ class PdfDistributionPlot(AbstractPlot): ypad:Optional[float]=None): if comparison_options is not None: - ax, ax_ratio = self.draw_frame(ratio_frames, logx=logx, logy=logy, - **self.styles["ratio_frames"]) + ax, ax_ratio = self.draw_frame(ratio=True, logx=logx, logy=logy) else: - ax = self.draw_frame(logx=logx, logy=logy) + ax = self.draw_frame(ratio=False, logx=logx, logy=logy) if targets is None: targets = list(self.collective_data) @@ -457,8 +456,7 @@ class PdfDistributionPlot(AbstractPlot): self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel) self.set_axis_range(ax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, ypad=ypad) - handles, labels = self.get_legend_handles_labels() - ax.legend(handles, labels, **self.styles['legend']) + self.draw_legend(ax) if comparison_options is not None: if not isinstance(comparison_options, list): diff --git a/quickstats/plots/sample_purity_plot.py b/quickstats/plots/sample_purity_plot.py index 083a561dab9e0579c6616c2116940a111411a44f..0184b8f1a319427a7e616d0981e8228710cc1177 100644 --- a/quickstats/plots/sample_purity_plot.py +++ b/quickstats/plots/sample_purity_plot.py @@ -120,10 +120,13 @@ class SamplePurityPlot(AbstractPlot): continue ax.text(x, y, to_string(c, precision), ha='center', va='center', color=text_color) - legend_styles = combine_dict(self.styles['legend']) - if ('ncol' in legend_styles) and (legend_styles['ncol'] == 'auto'): - legend_styles['ncol'] = len(categories) - ax.legend(**legend_styles) + if (('ncol' in self.styles['legend']) and + (self.styles['legend']['ncol'] == 'auto')): + legend_styles = {"ncol": len(categories)} + else: + legend_styles = {} + + self.draw_legend(ax, **legend_styles) return ax \ No newline at end of file diff --git a/quickstats/plots/score_distribution_plot.py b/quickstats/plots/score_distribution_plot.py index 08ebec66d32f7561be1dfa89e98e3260792348ea..ff7de668bd81f128881b73991c07196e16cdd8f1 100644 --- a/quickstats/plots/score_distribution_plot.py +++ b/quickstats/plots/score_distribution_plot.py @@ -117,7 +117,7 @@ class ScoreDistributionPlot(AbstractPlot): handles, labels = ax.get_legend_handles_labels() new_handles = [Line2D([], [], c=h.get_edgecolor(), linestyle=h.get_linestyle(), **self.styles['legend_Line2D']) if isinstance(h, Polygon) else h for h in handles] - ax.legend(handles=new_handles, labels=labels, **self.styles['legend']) + self.draw_legend(ax, handles=new_handles, labels=labels) if boundaries is not None: for boundary in boundaries: ax.axvline(x=boundary, **self.config["boundary_style"]) @@ -157,7 +157,7 @@ def score_distribution_plot(dfs:Dict[str, pd.DataFrame], hist_options:Dict[str, handles, labels = ax.get_legend_handles_labels() new_handles = [Line2D([], [], c=h.get_edgecolor(), linestyle=h.get_linestyle(), **styles['legend_Line2D']) if isinstance(h, Polygon) else h for h in handles] - ax.legend(handles=new_handles, labels=labels, **styles['legend']) + self.draw_legend(ax, new_handles, labels) if boundaries is not None: for boundary in boundaries: ax.axvline(x=boundary, ymin=0, ymax=0.5, linestyle='--', color='k') diff --git a/quickstats/plots/template.py b/quickstats/plots/template.py index 0be787dadec38a85848c812ef12be83759adfbd8..7b7afbaf50702de33c4f5a6ad0f637d0467e995e 100644 --- a/quickstats/plots/template.py +++ b/quickstats/plots/template.py @@ -8,14 +8,16 @@ import numpy as np import matplotlib.pyplot as plt import matplotlib.transforms as transforms from matplotlib.patches import Rectangle, Polygon -from matplotlib.collections import PolyCollection +from matplotlib.collections import (PolyCollection, LineCollection, PathCollection) from matplotlib.lines import Line2D from matplotlib.ticker import (MultipleLocator, FormatStrFormatter, AutoMinorLocator, ScalarFormatter, Locator, Formatter, AutoLocator, LogFormatter, LogFormatterSciNotation, MaxNLocator) - +from matplotlib.legend_handler import (HandlerLine2D, + HandlerLineCollection, + HandlerPathCollection) from quickstats.utils.common_utils import combine_dict from quickstats import DescriptiveEnum @@ -67,6 +69,26 @@ class LogNumericFormatter(LogFormatterSciNotation): result = super().__call__(x, pos) #result = result.replace('10^{1}', '10').replace('10^{0}', '1') return result + +class CustomHandlerLineCollection(HandlerLineCollection): + def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + artists = super().create_artists(legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans) + # Adjust line height to center in legend + for artist in artists: + artist.set_ydata([height / 2.0, height / 2.0]) + return artists + +class CustomHandlerPathCollection(HandlerPathCollection): + def create_artists(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans): + artists = super().create_artists(legend, orig_handle, xdescent, ydescent, width, height, fontsize, trans) + # Modify the path collection offsets to center the markers in the legend + for artist in artists: + offsets = np.array([[width / 2.0, height / 2.0]]) + artist.set_offsets(offsets) + return artists + +CUSTOM_HANDLER_MAP = {LineCollection: CustomHandlerLineCollection(), + PathCollection: CustomHandlerPathCollection()} TEMPLATE_STYLES = { 'default': { @@ -166,7 +188,7 @@ TEMPLATE_STYLES = { "fontsize": 20, "columnspacing": 0.8 }, - 'ratio_frames':{ + 'ratio_frame':{ 'height_ratios': (3, 1), 'hspace': 0.07 }, @@ -244,44 +266,43 @@ def parse_analysis_label_options(options:Optional[Dict]=None): options = combine_dict(default_options, options) return options -def ratio_frames(height_ratios:Tuple[int]=(3, 1), hspace:float=0.07, - logx:bool=False, logy:bool=False, - styles:Optional[Union[Dict, str]]=None, - analysis_label_options:Optional[Union[Dict, str]]=None, - prop_cycle:Optional[List[str]]=None, - figure_index:Optional[int]=None): +def ratio_frame(logx:bool=False, logy:bool=False, + styles:Optional[Union[Dict, str]]=None, + analysis_label_options:Optional[Union[Dict, str]]=None, + prop_cycle:Optional[List[str]]=None, + figure_index:Optional[int]=None): if figure_index is None: plt.clf() else: plt.figure(figure_index) styles = parse_styles(styles) gridspec_kw = { - "height_ratios": height_ratios, - "hspace": hspace + "height_ratios": styles['ratio_frame']['height_ratios'], + "hspace": styles['ratio_frame']['hspace'] } - fig, (ax1, ax2) = plt.subplots(nrows=2, ncols=1, gridspec_kw=gridspec_kw, - sharex=True, **styles['figure']) + fig, (ax_main, ax_ratio) = plt.subplots(nrows=2, ncols=1, gridspec_kw=gridspec_kw, + sharex=True, **styles['figure']) if logx: - ax1.set_xscale('log') - ax2.set_xscale('log') + ax_main.set_xscale('log') + ax_ratio.set_xscale('log') if logy: - ax1.set_yscale('log') + ax_main.set_yscale('log') - ax1_styles = combine_dict(styles['axis'], {"x_axis_styles": {"labelbottom": False}}) - format_axis_ticks(ax1, x_axis=True, y_axis=True, xtick_styles=styles['xtick'], - ytick_styles=styles['ytick'], **ax1_styles) - format_axis_ticks(ax2, x_axis=True, y_axis=True, xtick_styles=styles['xtick'], + ax_main_styles = combine_dict(styles['axis'], {"x_axis_styles": {"labelbottom": False}}) + format_axis_ticks(ax_main, x_axis=True, y_axis=True, xtick_styles=styles['xtick'], + ytick_styles=styles['ytick'], **ax_main_styles) + format_axis_ticks(ax_ratio, x_axis=True, y_axis=True, xtick_styles=styles['xtick'], ytick_styles=styles['ytick'], **styles['axis']) if analysis_label_options is not None: draw_analysis_label(ax1, text_options=styles['text'], **analysis_label_options) if prop_cycle is not None: - ax1.set_prop_cycle(prop_cycle) + ax_main.set_prop_cycle(prop_cycle) - return ax1, ax2 + return ax_main, ax_ratio def single_frame(logx:bool=False, logy:bool=False, styles:Optional[Union[Dict, str]]=None, @@ -665,7 +686,7 @@ def change_axis(axis): def draw_analysis_label(axis, loc=(0.05, 0.95), fontsize:float=25, status:str='int', energy:Optional[str]=None, lumi:Optional[str]=None, colab:Optional[str]='ATLAS', main_text:Optional[str]=None, - extra_text:Optional[str]=None, dy:float=0.05, dy_main:float=0.02, + extra_text:Optional[str]=None, dy:float=0.02, dy_main:float=0.01, transform_x:str='axis', transform_y:str='axis', vertical_align:str='top', horizontal_align:str='left', text_options:Optional[Dict]=None): diff --git a/quickstats/plots/test_statistic_distribution_plot.py b/quickstats/plots/test_statistic_distribution_plot.py index 76ea705727d3d8b4f77410b8e8d91d0ebad9a17e..958ba30d953c3c260da107c27e011aeed3e20460 100644 --- a/quickstats/plots/test_statistic_distribution_plot.py +++ b/quickstats/plots/test_statistic_distribution_plot.py @@ -171,14 +171,16 @@ class TestStatisticDistributionPlot(AbstractPlot): if asymptotic_handles is not None: primary_handles += asymptotic_handles primary_labels = [labels[handles.index(h)] for h in primary_handles] - primary_leg = ax.legend(primary_handles, primary_labels, - loc=leg_loc, ncol=2, **self.styles['legend']) + primary_leg = self.draw_legend(ax, handles=primary_handles, + labels=primary_labels, + loc=leg_loc, ncol=2) ax.add_artist(primary_leg) if secondary_handles is not None: secondary_labels = [labels[handles.index(h)] for h in secondary_handles] - primary_leg = ax.legend(secondary_handles, secondary_labels, - loc=leg_loc, ncol=2, **self.styles['legend']) + primary_leg = self.draw_legend(ax, handles=secondary_handles, + labels=secondary_labels, + loc=leg_loc, ncol=2) ax.add_artist(primary_leg) diff --git a/quickstats/plots/upper_limit_1D_plot.py b/quickstats/plots/upper_limit_1D_plot.py index 0117ca528bceb49a171d33ba33e9bb09667bf8b8..7ff1eac99348fb2263e0bfa5fe85fa4b03d1afd0 100644 --- a/quickstats/plots/upper_limit_1D_plot.py +++ b/quickstats/plots/upper_limit_1D_plot.py @@ -126,11 +126,9 @@ class UpperLimit1DPlot(AbstractPlot): text_pos = {'expected': 0.925} if draw_third_column: text_pos = {'observed': 0.725, 'expected': 0.825, 'third': 0.925} - - bak_verticalalignment = self.styles['text']['verticalalignment'] - bak_horizontalalignment = self.styles['text']['horizontalalignment'] - self.styles['text']['verticalalignment'] = 'center' - self.styles['text']['horizontalalignment'] = 'center' + text_styles = self.styles['text'].copy() + text_styles['verticalalignment'] = 'center' + text_styles['horizontalalignment'] = 'center' for i, category in enumerate(self.category_df): df = self.category_df[category] # draw observed @@ -143,8 +141,7 @@ class UpperLimit1DPlot(AbstractPlot): observed_handle = (handle_1, handle_2) if add_text: ax.text(text_pos['observed'], i + 0.5, f"{{:.{sig_fig}f}}".format(observed_limit), - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) else: observed_handle = None # draw stat @@ -152,16 +149,14 @@ class UpperLimit1DPlot(AbstractPlot): stat_limit = df['stat'] if add_text: ax.text(text_pos['stat'], i + 0.5, f"({{:.{sig_fig}f}})".format(stat_limit), - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) # draw expected expected_limit = df['0'] expected_handle = ax.vlines(expected_limit, i, i + 1, colors=self.color_pallete['expected'], linestyles='dotted', zorder=1.1, label=self.labels['expected']) if add_text: ax.text(text_pos['expected'], i + 0.5, f"{{:.{sig_fig}f}}".format(expected_limit), - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) # draw third if draw_third_column: third_limit = df['third'] @@ -169,8 +164,7 @@ class UpperLimit1DPlot(AbstractPlot): zorder=1.1, label=self.labels['third']) if add_text: ax.text(text_pos['third'], i + 0.5, f"{{:.{sig_fig}f}}".format(third_limit), - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) else: third_handle = None # draw error band @@ -212,21 +206,15 @@ class UpperLimit1DPlot(AbstractPlot): if add_text: if draw_observed: ax.text(text_pos['observed'], n_category + 0.3, 'Obs.', - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) if draw_stat: ax.text(text_pos['stat'], n_category + 0.3, '(Stat.)', - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) if draw_third_column: ax.text(text_pos['third'], n_category + 0.3, draw_third_column, - transform=transform, - **self.styles['text']) + transform=transform, **text_styles) ax.text(text_pos['expected'], n_category + 0.3, 'Exp.', - transform=transform, - **self.styles['text']) - self.styles['text']['verticalalignment'] = bak_verticalalignment - self.styles['text']['horizontalalignment'] = bak_horizontalalignment + transform=transform, **text_styles) if self.curve_data is not None: self.draw_curve(ax, self.curve_data) if xlabel is not None: @@ -234,6 +222,5 @@ class UpperLimit1DPlot(AbstractPlot): # border for the legend border_leg = patches.Rectangle( (0, 0), 1, 1, facecolor='none', edgecolor='black', linewidth=1) self.add_legend_decoration(border_leg, targets=["one_sigma", "two_sigma", "curve"]) - handles, labels = self.get_legend_handles_labels() - ax.legend(handles, labels, **self.styles['legend']) + self.draw_legend(ax) return ax diff --git a/quickstats/plots/upper_limit_2D_plot.py b/quickstats/plots/upper_limit_2D_plot.py index e69775ad68d3746b9db4ec675cbec50329145afb..bf278289c1acd9328fecf79623b51cce5692f055 100644 --- a/quickstats/plots/upper_limit_2D_plot.py +++ b/quickstats/plots/upper_limit_2D_plot.py @@ -458,6 +458,6 @@ class UpperLimit2DPlot(AbstractPlot): indices = sorted(self.legend_data_ext.keys()) handles, labels = self.get_legend_handles_labels(idx=indices) - ax.legend(handles, labels, **self.styles['legend']) + self.draw_legend(ax, handles, labels) return ax diff --git a/quickstats/plots/upper_limit_2D_plot_deprecated.py b/quickstats/plots/upper_limit_2D_plot_deprecated.py deleted file mode 100644 index e351337097c7d565778838edc1fee308e9f70f8f..0000000000000000000000000000000000000000 --- a/quickstats/plots/upper_limit_2D_plot_deprecated.py +++ /dev/null @@ -1,309 +0,0 @@ -from typing import Optional, Union, Dict, List - -import matplotlib.patches as patches -import matplotlib.lines as lines -import pandas as pd - -from quickstats.plots import AbstractPlot -from quickstats.utils.common_utils import combine_dict - -class UpperLimit2DPlot(AbstractPlot): - - STYLES = { - 'axis':{ - 'tick_bothsides': False - }, - 'errorbar': { - "linewidth": 1, - "markersize": 5, - "marker": 'o', - } - } - - COLOR_PALLETE = { - '2sigma': '#FDC536', - '1sigma': '#4AD9D9', - 'expected': 'k', - 'observed': 'k' - } - - COLOR_PALLETE_EXTRA = { - '2sigma': '#FDC536', - '1sigma': '#4AD9D9', - 'expected': 'r', - 'observed': 'r' - } - - LABELS = { - '2sigma': 'Expected limit $\pm 2\sigma$', - '1sigma': 'Expected limit $\pm 1\sigma$', - 'expected': 'Expected limit (95% CL)', - 'observed': 'Observed limit (95% CL)' - } - - LABELS_EXTRA = { - '2sigma': 'Expected limit $\pm 2\sigma$', - '1sigma': 'Expected limit $\pm 1\sigma$', - 'expected': 'Expected limit (95% CL)', - 'observed': 'Observed limit (95% CL)' - } - - CONFIG = { - 'primary_hatch' : '\\\\\\', - 'secondary_hatch': '///', - 'primary_alpha' : 0.9, - 'secondary_alpha': 0.8, - 'curve_line_styles': { - 'color': 'darkred' - }, - 'curve_fill_styles':{ - 'color': 'hh:darkpink' - }, - 'highlight_styles': { - 'linewidth' : 0, - 'marker' : '*', - 'markersize' : 20, - 'color' : '#E9F1DF', - 'markeredgecolor' : 'black' - }, - 'errorband_plot_styles':{ - 'alpha': 1 - }, - 'expected_plot_styles': { - 'marker': 'None', - 'linestyle': '--', - 'alpha': 1, - 'linewidth': 1 - }, - 'observed_plot_styles': { - 'marker': 'o', - 'alpha': 1, - 'linewidth': 1 - } - } - - def __init__(self, data:pd.DataFrame, - additional_data:Optional[List[Dict]]=None, - scale_factor:float=None, - color_pallete:Optional[Dict]=None, - labels:Optional[Dict]=None, - styles:Optional[Union[Dict, str]]=None, - analysis_label_options:Optional[Union[Dict, str]]='default', - config:Optional[Dict]=None): - super().__init__(color_pallete=color_pallete, - styles=styles, - analysis_label_options=analysis_label_options, - config=config) - self.data = data - - self.additional_data = [] - if additional_data is not None: - for _data in additional_data: - self.add_data(**_data) - - self.labels = combine_dict(self.LABELS, labels) - - self.scale_factor = scale_factor - - self.curve_data = None - self.highlight_data = None - - def get_default_legend_order(self): - return ['observed', 'expected', '1sigma', '2sigma', 'curve', 'highlight'] - - def add_curve(self, x, y, yerrlo=None, yerrhi=None, - label:str="Theory prediction", - line_styles:Optional[Dict]=None, - fill_styles:Optional[Dict]=None): - curve_data = { - 'x' : x, - 'y' : y, - 'yerrlo' : yerrlo, - 'yerrhi' : yerrhi, - 'label' : label, - 'line_styles': line_styles, - 'fill_styles': fill_styles, - } - self.curve_data = curve_data - - def add_highlight(self, x:float, y:float, label:str="SM prediction", - styles:Optional[Dict]=None): - highlight_data = { - 'x' : x, - 'y' : y, - 'label' : label, - 'styles': styles - } - self.highlight_data = highlight_data - - def draw_curve(self, ax, data): - line_styles = data['line_styles'] - fill_styles = data['fill_styles'] - if line_styles is None: - line_styles = self.config['curve_line_styles'] - if fill_styles is None: - fill_styles = self.config['curve_fill_styles'] - if (data['yerrlo'] is None) and (data['yerrhi'] is None): - line_styles['color'] = fill_styles['color'] - handle_line = ax.plot(data['x'], data['y'], label=data['label'], **line_styles) - handles = handle_line[0] - if (data['yerrlo'] is not None) and (data['yerrhi'] is not None): - handle_fill = ax.fill_between(data['x'], data['yerrlo'], data['yerrhi'], - label=data['label'], **fill_styles) - handles = (handle_fill, handle_line[0]) - self.update_legend_handles({'curve': handles}, idx=0) - - def draw_highlight(self, ax, data): - styles = data['styles'] - if styles is None: - styles = self.config['highlight_styles'] - handle = ax.plot(data['x'], data['y'], label=data['label'], **styles) - self.update_legend_handles({'highlight': handle[0]}, idx=0) - - def draw_single_data(self, ax, data, scale_factor=None, - log:bool=False, - draw_expected:bool=True, - draw_observed:bool=True, - color_pallete:Optional[Dict]=None, - labels:Optional[Dict]=None, - sigma_band_hatch:Optional[str]=None, - draw_errorband:bool=True, - idx:int=0): - - if color_pallete is None: - color_pallete = self.color_pallete - if labels is None: - labels = self.labels - if scale_factor is None: - scale_factor = 1.0 - - indices = data.index.astype(float).values - exp_limits = data['0'].values * scale_factor - n1sigma_limits = data['-1'].values * scale_factor - n2sigma_limits = data['-2'].values * scale_factor - p1sigma_limits = data['1'].values * scale_factor - p2sigma_limits = data['2'].values * scale_factor - - handles_map = {} - - # draw +- 1, 2 sigma bands - if draw_errorband: - handle_2sigma = ax.fill_between(indices, n2sigma_limits, p2sigma_limits, - facecolor=color_pallete['2sigma'], - label=labels['2sigma'], - hatch=sigma_band_hatch, - **self.config["errorband_plot_styles"]) - handle_1sigma = ax.fill_between(indices, n1sigma_limits, p1sigma_limits, - facecolor=color_pallete['1sigma'], - label=labels['1sigma'], - hatch=sigma_band_hatch, - **self.config["errorband_plot_styles"]) - handles_map['1sigma'] = handle_1sigma - handles_map['2sigma'] = handle_2sigma - - if log: - draw_fn = ax.semilogy - else: - draw_fn = ax.plot - - if draw_observed: - obs_limits = data['obs'].values * scale_factor - handle_observed = draw_fn(indices, obs_limits, color=color_pallete['observed'], - label=labels['observed'], - **self.config["observed_plot_styles"]) - handles_map['observed'] = handle_observed[0] - - if draw_expected: - handle_expected = draw_fn(indices, exp_limits, color=color_pallete['expected'], - label=labels['expected'], - **self.config["expected_plot_styles"]) - handles_map['expected'] = handle_expected[0] - - self.update_legend_handles(handles_map, idx=idx) - - def add_data(self, data:pd.DataFrame, color_pallete:Optional[Dict]=None, - labels:Optional[Dict]=None, draw_expected:bool=True, - draw_observed:bool=False, - draw_errorband:bool=False): - config = { - "data": data, - "color_pallete": combine_dict(self.COLOR_PALLETE_EXTRA, color_pallete), - "labels": combine_dict(self.LABELS_EXTRA, labels), - "draw_observed": draw_observed, - "draw_expected": draw_expected, - "draw_errorband": draw_errorband - } - self.additional_data.append(config) - - def draw(self, xlabel:str="", ylabel:str="", ylim=None, xlim=None, - log:bool=False, draw_expected:bool=True, - draw_observed:bool=True, draw_errorband:bool=True, - draw_sec_errorband:bool=False, draw_hatch:bool=True): - - ax = self.draw_frame() - - if len(self.additional_data) > 0: - if draw_hatch: - sigma_band_hatch = self.config['secondary_hatch'] - alpha = self.config['secondary_alpha'] - else: - sigma_band_hatch = None - alpha = 1. - for idx, config in enumerate(self.additional_data): - self.draw_single_data(ax, config["data"], - scale_factor=self.scale_factor, - log=log, - draw_expected=config["draw_expected"], - draw_observed=config["draw_observed"], - color_pallete=config["color_pallete"], - labels=config["labels"], - sigma_band_hatch=sigma_band_hatch, - draw_errorband=config["draw_errorband"], - idx=idx + 1) - if draw_hatch: - sigma_band_hatch = self.config['primary_hatch'] - alpha = self.config['primary_alpha'] - else: - sigma_band_hatch = None - alpha = 1. - else: - sigma_band_hatch = None - alpha = 1. - self.draw_single_data(ax, self.data, - scale_factor=self.scale_factor, - log=log, - draw_expected=draw_expected, - draw_observed=draw_observed, - color_pallete=self.color_pallete, - labels=self.labels, - sigma_band_hatch=sigma_band_hatch, - draw_errorband=draw_errorband, - idx=0) - - if self.curve_data is not None: - self.draw_curve(ax, self.curve_data) - if self.highlight_data is not None: - self.draw_highlight(ax, self.highlight_data) - - self.draw_axis_components(ax, xlabel=xlabel, ylabel=ylabel) - - if ylim is not None: - ax.set_ylim(*ylim) - if xlim is not None: - ax.set_xlim(*xlim) - - # border for the legend - border_leg = patches.Rectangle((0, 0), 1, 1, facecolor = 'none', edgecolor = 'black', linewidth = 1) - for legend_data in self.legend_data_ext.values(): - for sigma in ['1sigma', '2sigma']: - if sigma in legend_data: - legend_data[sigma]['handle'] = (legend_data[sigma]['handle'], border_leg) - - if self.curve_data is not None: - if isinstance(self.legend_data_ext[0]['curve']['handle'], tuple): - self.legend_data_ext[0]['curve']['handle'] = (*self.legend_data_ext[0]['curve']['handle'], border_leg) - - indices = sorted(self.legend_data_ext.keys()) - handles, labels = self.get_legend_handles_labels(idx=indices) - ax.legend(handles, labels, **self.styles['legend']) - return ax diff --git a/quickstats/plots/upper_limit_3D_plot.py b/quickstats/plots/upper_limit_3D_plot.py index 738a3c397bf10486548bb9b023b43e7298b64475..431f7f6ae71ad9d64725c9cb60ccdf375716e076 100644 --- a/quickstats/plots/upper_limit_3D_plot.py +++ b/quickstats/plots/upper_limit_3D_plot.py @@ -291,7 +291,7 @@ class UpperLimit3DPlot(AbstractPlot): handles_sec, labels_sec = self.get_legend_handles_labels(sec=True) handles = handles + handles_sec labels = labels + labels_sec - ax.legend(handles, labels, **self.styles['legend']) + self.draw_legend(ax, handles, labels) return ax diff --git a/quickstats/plots/upper_limit_benchmark_plot.py b/quickstats/plots/upper_limit_benchmark_plot.py index 82358b35537f326913ec2d857db85d8b56f1cc05..e192473c351ede6dbbb7b6e2c5faf4c07460eff4 100644 --- a/quickstats/plots/upper_limit_benchmark_plot.py +++ b/quickstats/plots/upper_limit_benchmark_plot.py @@ -7,7 +7,7 @@ from matplotlib.patches import Polygon import numpy as np import pandas as pd -from quickstats.plots.template import single_frame, ratio_frames, create_transform, remake_handles +from quickstats.plots.template import create_transform, remake_handles from quickstats.plots import AbstractPlot from quickstats.utils.common_utils import combine_dict from quickstats.maths.statistics import HistComparisonMode @@ -463,10 +463,9 @@ class UpperLimitBenchmarkPlot(AbstractPlot): xticklabels = list(xticklabels) if comparison_options is not None: - ax, ax_ratio = self.draw_frame(ratio_frames, logy=logy, - **self.styles["ratio_frames"]) + ax, ax_ratio = self.draw_frame(ratio=True, logy=logy) else: - ax = self.draw_frame(logy=logy) + ax = self.draw_frame(ratio=False, logy=logy) eps = self.config['sigma_width'] / 2 xmargin = self.config['xmargin'] @@ -525,7 +524,7 @@ class UpperLimitBenchmarkPlot(AbstractPlot): handles = remake_handles(handles, polygon_to_line=False, fill_border=True, border_styles=self.styles['legend_border']) handler_map = {ErrorbarContainer: HandlerErrorbar(xerr_size=1)} - ax.legend(handles, labels, **self.styles['legend'], handler_map=handler_map) + self.darw_legend(handles, labels, handler_map=handler_map) if comparison_options is not None: return ax, ax_ratio diff --git a/quickstats/plots/variable_distribution_plot.py b/quickstats/plots/variable_distribution_plot.py index 9e37c90123dd83b8463fbb46b8d33e42956e3645..dc0e8b7938c5f1722f8975baad6dca185dbbdb05 100644 --- a/quickstats/plots/variable_distribution_plot.py +++ b/quickstats/plots/variable_distribution_plot.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Dict, List, Sequence +from typing import Optional, Union, Dict, List, Sequence, Tuple, Callable import pandas as pd import numpy as np @@ -8,8 +8,8 @@ from matplotlib.lines import Line2D from matplotlib.patches import Polygon from quickstats.plots import AbstractPlot, get_color_cycle -from quickstats.plots.template import ratio_frames, centralize_axis, remake_handles -from quickstats.utils.common_utils import combine_dict +from quickstats.plots.template import centralize_axis, remake_handles +from quickstats.utils.common_utils import combine_dict, remove_duplicates from quickstats.maths.numerics import safe_div from quickstats.maths.statistics import (HistComparisonMode, min_max_to_range, get_hist_data, @@ -58,11 +58,10 @@ class VariableDistributionPlot(AbstractPlot): }, 'plot_format': 'hist', 'error_format': 'shade', - 'error_label_format': r'{label} $\pm \sigma$', + 'error_label_format': r'{label}', 'show_xerr': False, 'stacked_label': ':stacked_{index}:', - 'box_legend_handle': False, - 'save_hist_data': False + 'box_legend_handle': False } def __init__(self, data_map:Union["pandas.DataFrame", Dict[str, "pandas.DataFrame"]], @@ -91,27 +90,37 @@ class VariableDistributionPlot(AbstractPlot): "error_styles": <options in mpl.bar>, "plot_format": "hist" or "errorbar", "show_error": True or False, - "stack_index": <stack index> + "stack_index": <stack index>, + "hide": <list of callables / 2-tuples> } } - where "styles" should match the options available in mpl.hist if - `plot_format` = "hist" or mpl.errorbar if `plot_format` = "errorbar" - (optional) "weight_scale" is used to scale the weights of the given - group of samples by the given factor + "styles" should match the options available in mpl.hist if + `plot_format` = "hist" or mpl.errorbar if `plot_format` = "errorbar" "error_styles" should match the options available in mpl.errorbar if `error_format` = "errorbar", mpl.bar if `error_format` = "shade" or mpl.fill_between if `error_format` = "fill" + + (optional) "weight_scale" is used to scale the weights of the given + group of samples by the given factor (optional) "show_error" is used to specify whether to show the errorbar/ errorbands for this particular target. - (optional) "stack_index" is be used when multiple stacked plots are made; + (optional) "stack_index" is used when multiple stacked plots are made; sample groups with the same stack index will be stacked; this option is only used when `plot_format` = "hist" and the draw method is called with the `stack` option set to True; by default a stack index of 0 will be assigned + + (optional) "hide" defines the condition to hide portion of the data in + the plot; in case of a 2-tuple, it specifies the (start, end) range of + data that should be hidden; in case of a callable, it is a function + that takes as input the value of the variable, and outputs a boolean + value indicating whether the data should be hidden; + + Note: If "samples" is not given, it will default to [<sample_group>] Note: If both `plot_format` and `error_format` are errorbar, "styles" will be used instead of "error_styles" for the error styles. @@ -133,6 +142,11 @@ class VariableDistributionPlot(AbstractPlot): data_map = {None: data_map} self.data_map = data_map + def reset_hist_data(self): + self.hist_data = {} + self.hist_bin_edges = {} + self.hist_comparison_data = [] + def set_plot_format(self, plot_format:str): self.config['plot_format'] = PlotFormat.parse(plot_format) @@ -141,33 +155,48 @@ class VariableDistributionPlot(AbstractPlot): def is_single_data(self): return (None in self.data_map) and (len(self.data_map) == 1) - - def resolve_plot_options(self, plot_options:Optional[Dict]=None, - targets:Optional[List[str]]=None): + + def resolve_targets(self, targets:Optional[List[str]]=None, + plot_options:Optional[Dict]=None): if self.is_single_data(): if targets is not None: - raise ValueError('no targets should be specified if only a single set of input data is given') + raise ValueError('no targets should be specified if only one set of input data is given') targets = [None] elif targets is None: + all_samples = list(self.data_map.keys()) + targets = [] if plot_options is not None: - targets = list(plot_options.keys()) - elif isinstance(self.data_map, dict): - targets = list(self.data_map.keys()) - final_plot_options = {} - plot_colors = self.get_colors() - n_colors, color_i = len(plot_colors), 0 - if plot_options is None: - plot_options = {} + grouped_samples = [] + for key in plot_options: + samples = plot_options[key].get("samples", [key]) + grouped_samples.extend([sample for sample in samples \ + if sample not in grouped_samples]) + targets.append(key) + targets.extend([sample for sample in all_samples \ + if sample not in grouped_samples]) + else: + targets = all_samples + return targets + + def resolve_plot_options(self, plot_options:Optional[Dict]=None, + targets:Optional[List[str]]=None): + plot_options = plot_options or self.plot_options or {} + targets = self.resolve_targets(targets, plot_options=plot_options) + resolved_plot_options = {} + colors = self.get_colors() + n_colors, color_i = len(colors), 0 if self.label_map is not None: label_map = self.label_map else: label_map = {} for target in targets: options = combine_dict(plot_options.get(target, {})) + # use global plot format if not specified if 'plot_format' not in options: options['plot_format'] = PlotFormat.parse(self.config['plot_format']) else: options['plot_format'] = PlotFormat.parse(options['plot_format']) + # use global error format if not specified if 'error_format' not in options: if options['plot_format'] == PlotFormat.ERRORBAR: options['error_format'] = ErrorDisplayFormat.ERRORBAR @@ -183,11 +212,12 @@ class VariableDistributionPlot(AbstractPlot): if 'color' not in options['styles']: if color_i == n_colors: self.stdout.warning("Number of targets is more than the number of colors " - "available in the color map. The colors will be repeated.") - options['styles']['color'] = plot_colors[color_i % n_colors] + "available in the color map. The colors will be recycled.") + options['styles']['color'] = colors[color_i % n_colors] color_i += 1 if 'label' not in options['styles']: label = label_map.get(target, target) + # handle case of single data (no label needed) if label is None: label = 'None' options['styles']['label'] = label @@ -197,6 +227,7 @@ class VariableDistributionPlot(AbstractPlot): options['error_styles'] = combine_dict(self.get_styles(options['error_format'].mpl_method)) else: options['error_styles'] = combine_dict(self.get_styles(options['error_format'].mpl_method), options['error_styles']) + # reuse color of the plot for the error by default if 'color' not in options['error_styles']: options['error_styles']['color'] = options['styles']['color'] if 'label' not in options['error_styles']: @@ -206,8 +237,70 @@ class VariableDistributionPlot(AbstractPlot): options['stack_index'] = 0 if 'weight_scale' not in options: options['weight_scale'] = None - final_plot_options[target] = options - return final_plot_options + if 'hide' not in options: + options['hide'] = None + resolved_plot_options[target] = options + return resolved_plot_options + + def _merge_styles(self, styles_list:List[Dict]): + merged_styles = {} + sequence_args = ["color", "label"] + for styles in styles_list: + styles = styles.copy() + for key, value in styles.items(): + if key in sequence_args: + if key not in merged_styles: + merged_styles[key] = [] + merged_styles[key].append(value) + continue + if (key in merged_styles) and (value != merged_styles[key]): + raise ValueError('failed to merge style options for targets in a stacked plot: ' + f'found inconsistent values for the option "{key}"') + merged_styles[key] = value + return merged_styles + + def resolve_stacked_plot_options(self, plot_options:Dict): + stacked_plot_options = {} + targets = [target for target, options in plot_options.items() if \ + options['plot_format'] == PlotFormat.HIST] + if not targets: + raise RuntimeError('no histograms to be stacked') + target_map = {} + for target in targets: + stack_index = plot_options[target]['stack_index'] + if stack_index not in target_map: + target_map[stack_index] = [] + target_map[stack_index].append(target) + stacked_plot_options = {} + for stack_index, targets in target_map.items(): + options = {} + options["components"] = {} + styles_list = [] + hide_list = [] + error_styles_list = [] + error_format_list = [] + for target in targets: + # modify the dictionary (intended) + target_options = plot_options.pop(target) + styles = target_options.pop("styles") + error_styles = target_options.pop("error_styles") + error_format = target_options.pop("error_format") + hide = target_options.pop("hide") + styles_list.append(styles) + error_styles_list.append(error_styles) + error_format_list.append(error_format) + hide_list.append(hide) + options["components"][target] = target_options + options['plot_format'] = PlotFormat.HIST + options['error_format_list'] = error_format_list + options['styles'] = self._merge_styles(styles_list) + options['error_styles_list'] = error_styles_list + options['hide_list'] = hide_list + label = self.config["stacked_label"].format(index=stack_index) + if label in stacked_plot_options: + raise RuntimeError(f"duplicated stack label: {label}") + stacked_plot_options[label] = options + return stacked_plot_options def resolve_comparison_options(self, comparison_options:Optional[Dict]=None, plot_options:Optional[Dict]=None): @@ -249,7 +342,7 @@ class VariableDistributionPlot(AbstractPlot): if target in plot_options: component['styles']['color'] = plot_options[target]['styles']['color'] else: - component['styles']['color'] = plot_colors[color_i % n_colors] + component['styles']['color'] = colors[color_i % n_colors] color_i += 1 if 'color' not in component['error_styles']: if target in plot_options: @@ -289,13 +382,14 @@ class VariableDistributionPlot(AbstractPlot): ylim[1] = np.max(y) ax.set_ylim(ylim) - if self.config['save_hist_data']: - self.hist_comparison_data.append(comparison_data) + self.hist_comparison_data.append(comparison_data) return handle, error_handle def deduce_bin_range(self, samples:List[str], column_name:str, variable_scale:Optional[float]=None): + """Deduce bin range based on variable ranges from multiple samples + """ xmin = None xmax = None for sample in samples: @@ -330,78 +424,102 @@ class VariableDistributionPlot(AbstractPlot): if weight_scale is not None: weights = weights * weight_scale return x, weights - - def draw_stacked(self, ax, plot_options:Dict, - column_name:str, weight_name:Optional[str]=None, - bins:Union[int, Sequence]=25, - bin_range:Optional[Sequence]=None, - clip_weight:bool=False, - underflow:bool=False, - overflow:bool=False, - divide_bin_width:bool=False, - normalize:bool=True, - show_error:bool=False, - variable_scale:Optional[float]=None): + + def draw_stacked_target(self, ax, stack_target:str, + components:Dict, + column_name:str, + plot_format:Union[PlotFormat, str], + error_format_list:List[Union[ErrorDisplayFormat, str]], + hist_options:Dict, + styles:Dict, + error_styles_list:List[Dict], + variable_scale:Optional[float]=None, + weight_name:Optional[str]=None, + show_error:bool=False, + hide_list:Optional[List[Union[Tuple[float, float], Callable]]]=None): stacked_data = { - 'x' : [], - 'weights' : [], - 'color' : [], - 'label' : [], + 'x' : [], + 'y' : [] } - - stacked_styles = [] - for target, options in plot_options.items(): - samples, styles = options['samples'], options['styles'] - label, color = styles['label'], styles['color'] + + for target, options in components.items(): + samples = options['samples'] weight_scale = options['weight_scale'] - x, weights = self.get_sample_data(samples, column_name, - variable_scale=variable_scale, - weight_scale=weight_scale, - weight_name=weight_name) - x = get_clipped_data(x, bin_range=bin_range, clip_lower=underflow, clip_upper=overflow) + x, y = self.get_sample_data(samples, column_name, + variable_scale=variable_scale, + weight_scale=weight_scale, + weight_name=weight_name) + x = get_clipped_data(x, + bin_range=hist_options["bin_range"], + clip_lower=hist_options["underflow"], + clip_upper=hist_options["overflow"]) stacked_data['x'].append(x) - stacked_data['weights'].append(weights) - stacked_data['color'].append(color) - stacked_data['label'].append(label) - stacked_styles.append(styles) + stacked_data['y'].append(y) + bin_edges = np.histogram_bin_edges(np.concatenate(stacked_data['x']).flatten(), - bins=bins, range=bin_range) - hist_data = get_stacked_hist_data(stacked_data['x'], stacked_data['weights'], - underflow=underflow, - overflow=overflow, - divide_bin_width=divide_bin_width, - normalize=normalize, - bin_range=bin_range, bins=bins, - clip_weight=clip_weight, - xerr=show_error and self.config['show_xerr'], + bins=hist_options["bins"], + range=hist_options["bin_range"]) + show_xerr = show_error and self.config['show_xerr'] + hist_data = get_stacked_hist_data(stacked_data['x'], + stacked_data['y'], + xerr=show_xerr, yerr=show_error, - error_option='auto') - stacked_styles = {k:v for k,v in stacked_styles[0].items() if k not in ['color', 'label']} - stacked_data_processed = get_stacked_hist_data(stacked_data['x'], stacked_data['weights'], - underflow=underflow, - overflow=overflow, - divide_bin_width=divide_bin_width, - normalize=normalize, - bin_range=bin_range, bins=bins, - clip_weight=clip_weight, - xerr=False, - yerr=False, - merge=False, - error_option='auto') - stacked_data['x'] = stacked_data_processed['x'] - stacked_data['weights'] = stacked_data_processed['y'] - hist_y, bin_edges_, handle = ax.hist(**stacked_data, - bins=bins, - range=bin_range, - stacked=True, - **stacked_styles) - for i, target in enumerate(plot_options): - self.update_legend_handles({target:handle[i]}) - return bin_edges, hist_data + error_option='auto', + **hist_options) + stacked_data = get_stacked_hist_data(stacked_data['x'], stacked_data['y'], + xerr=show_xerr, + yerr=show_error, + merge=False, + **hist_options) + handles = self.draw_stacked_binned_data(ax, stacked_data, + bin_edges=bin_edges, + plot_format=plot_format, + error_format_list=error_format_list, + draw_error=show_error, + hide_list=hide_list, + styles=styles, + error_styles_list=error_styles_list) + for i, target in enumerate(components): + self.update_legend_handles({target: handles[i]}) + self.hist_data[stack_target] = hist_data + self.hist_bin_edges[stack_target] = bin_edges + #self.update_legend_handles({stack_target: handles}) - def reset_hist_data(self): - self.hist_data = {} - self.hist_comparison_data = [] + def draw_single_target(self, ax, target:str, samples:List[str], + column_name:str, + styles:Dict, error_styles:Dict, + plot_format:Union[PlotFormat, str], + error_format:Union[ErrorDisplayFormat, str], + hist_options:Dict, + variable_scale:Optional[float]=None, + weight_name:Optional[str]=None, + weight_scale:Optional[float]=None, + show_error:bool=False, + hide:Optional[Union[Tuple[float, float], Callable]]=None): + x, weights = self.get_sample_data(samples, column_name, + variable_scale=variable_scale, + weight_scale=weight_scale, + weight_name=weight_name) + bin_edges = np.histogram_bin_edges(x, + bins=hist_options["bins"], + range=hist_options["bin_range"]) + show_xerr = show_error and self.config['show_xerr'] + hist_data = get_hist_data(x, weights, + xerr=show_xerr, + yerr=show_error, + error_option='auto', + **hist_options) + handles = self.draw_binned_data(ax, hist_data, + bin_edges=bin_edges, + styles=styles, + draw_error=show_error, + plot_format=plot_format, + error_format=error_format, + error_styles=error_styles, + hide=hide) + self.hist_data[target] = hist_data + self.hist_bin_edges[target] = bin_edges + self.update_legend_handles({target: handles}) def draw(self, column_name:str, weight_name:Optional[str]=None, targets:Optional[List[str]]=None, @@ -409,7 +527,7 @@ class VariableDistributionPlot(AbstractPlot): unit:Optional[str]=None, bins:Union[int, Sequence]=25, bin_range:Optional[Sequence]=None, clip_weight:bool=True, underflow:bool=False, overflow:bool=False, divide_bin_width:bool=False, - normalize:bool=True, show_error:bool=False, show_error_legend:bool=False, + normalize:bool=True, show_error:bool=False, stacked:bool=False, xmin:Optional[float]=None, xmax:Optional[float]=None, ymin:Optional[float]=None, ymax:Optional[float]=None, ypad:float=0.3, variable_scale:Optional[float]=None, logy:bool=False, @@ -453,8 +571,10 @@ class VariableDistributionPlot(AbstractPlot): content could be less than one. show_error: bool, default = False Whether to display data error. - show_error_legend: bool, default = False - Whether to include legend for the error artists. + stacked: bool, default = False + Do a stacked plot. Only histograms will be stacked (i.e. plot format + is not errorbar). Samples with different stack_index will be stacked + independently. xmin: (optional) float Minimum range of x-axis. xmax: (optional) float @@ -476,103 +596,65 @@ class VariableDistributionPlot(AbstractPlot): legend_order: (optional) list of str Order of legend labels. The same order as targets will be used by default. """ - plot_options = self.resolve_plot_options(self.plot_options, targets=targets) - comparison_options = self.resolve_comparison_options(comparison_options, - plot_options) + plot_options = self.resolve_plot_options(targets=targets) + relevant_samples = remove_duplicates([sample for options in plot_options.values() \ + for sample in options['samples']]) + if not relevant_samples: + raise RuntimeError('no targets to draw') + if stacked: + # this will remove targets that participates in stacking + stacked_plot_options = self.resolve_stacked_plot_options(plot_options) + else: + stacked_plot_options = {} + comparison_options = self.resolve_comparison_options(comparison_options, plot_options) if legend_order is not None: self.legend_order = list(legend_order) else: self.legend_order = list(plot_options) - if show_error_legend and (not stacked): - self.legend_order.extend([f"{target}_error" for target in self.legend_order]) + for options in stacked_plot_options.values(): + self.legend_order.extend([target for target in list(options['components']) \ + if target not in self.legend_order]) if comparison_options is not None: - ax, ax_ratio = self.draw_frame(ratio_frames, logy=logy, - **self.styles["ratio_frames"]) + ax, ax_ratio = self.draw_frame(ratio=True, logy=logy) else: - ax = self.draw_frame(logy=logy) + ax = self.draw_frame(ratio=False, logy=logy) if (bin_range is None) and isinstance(bins, (int, float)): - relevant_samples = [sample for options in plot_options.values() \ - for sample in options['samples']] bin_range = self.deduce_bin_range(relevant_samples, column_name, variable_scale=variable_scale) self.stdout.info(f"Using deduced bin range ({bin_range[0]:.3f}, {bin_range[1]:.3f})") self.reset_hist_data() - binned_data = {} - target_bin_edges = {} + hist_options = { + "bins" : bins, + "bin_range" : bin_range, + "underflow" : underflow, + "overflow" : overflow, + "normalize" : normalize, + "clip_weight" : clip_weight, + "divide_bin_width" : divide_bin_width + } + data_options = { + 'column_name': column_name, + 'weight_name': weight_name, + 'variable_scale': variable_scale + } - stacked_plot_options = {} - if stacked: - stack_targets = [target for target, options in plot_options.items() if \ - options['plot_format'] == PlotFormat.HIST] - if not stack_targets: - raise RuntimeError('no histograms to be stacked') - for target in stack_targets: - options = plot_options.pop(target) - stack_index = options['stack_index'] - if stack_index not in stacked_plot_options: - stacked_plot_options[stack_index] = {} - stacked_plot_options[stack_index][target] = options - for stack_index, stacked_plot_options_i in stacked_plot_options.items(): - bin_edges, hist_data = self.draw_stacked(ax, stacked_plot_options_i, - column_name=column_name, - weight_name=weight_name, - bins=bins, bin_range=bin_range, - underflow=underflow, - overflow=overflow, - normalize=normalize, - clip_weight=clip_weight, - divide_bin_width=divide_bin_width, - variable_scale=variable_scale) - label = self.config['stacked_label'].format(index=stack_index) - binned_data[label] = hist_data - target_bin_edges[label] = bin_edges + for stack_target, options in stacked_plot_options.items(): + options['show_error'] = options.get('show_error', show_error) + self.draw_stacked_target(ax, stack_target=stack_target, + hist_options=hist_options, + **options, + **data_options) + for target, options in plot_options.items(): - samples, styles, error_styles = options['samples'], options['styles'], options['error_styles'] - label = styles['label'] - weight_scale = options['weight_scale'] - show_this_error = options.get('show_error', show_error) - plot_format, error_format = options['plot_format'], options['error_format'] - x, weights = self.get_sample_data(samples, column_name, - variable_scale=variable_scale, - weight_scale=weight_scale, - weight_name=weight_name) - bin_edges = np.histogram_bin_edges(x, bins=bins, range=bin_range) - hist_data = get_hist_data(x, weights, underflow=underflow, - overflow=overflow, normalize=normalize, - divide_bin_width=divide_bin_width, - bin_range=bin_range, bins=bins, - clip_weight=clip_weight, - xerr=show_this_error and self.config['show_xerr'], - yerr=show_this_error, - error_option='auto') - binned_data[target] = hist_data - target_bin_edges[target] = bin_edges - if plot_format == PlotFormat.HIST: - # draw data - hist_y, _, handle = ax.hist(hist_data['x'], bins, range=bin_range, - weights=hist_data['y'], **styles) - assert np.allclose(hist_data['y'], hist_y) - # draw error only - handles = self.draw_binned_data(ax, hist_data, - bin_edges=bin_edges, - draw_data=False, - draw_error=show_this_error, - error_format=error_format, - error_styles=error_styles) - if not isinstance(handle, list): - handle = [handle] - handles = tuple(list(handles) + handle) - elif plot_format == PlotFormat.ERRORBAR: - handles = self.draw_binned_data(ax, hist_data, - bin_edges=bin_edges, - styles=styles, - draw_error=show_this_error, - error_format=error_format, - error_styles=error_styles) + options['show_error'] = options.get('show_error', show_error) + options.pop('stack_index', None) + self.draw_single_target(ax, target=target, + hist_options=hist_options, + **options, + **data_options) - self.update_legend_handles({target:handles}) # propagate bin width to ylabel if needed if isinstance(bins, int): bin_width = (bin_range[1] - bin_range[0]) / bins @@ -590,11 +672,10 @@ class VariableDistributionPlot(AbstractPlot): if not self.is_single_data(): handles, labels = self.get_legend_handles_labels() - box_legend_handle = self.config['box_legend_handle'] - if not box_legend_handle: + if not self.config['box_legend_handle']: handles = remake_handles(handles, polygon_to_line=True, line2d_styles=self.styles['legend_Line2D']) - ax.legend(handles=handles, labels=labels, **self.styles['legend']) + self.draw_legend(ax, handles=handles, labels=labels) if comparison_options is not None: components = comparison_options.pop('components') @@ -611,9 +692,6 @@ class VariableDistributionPlot(AbstractPlot): self.decorate_comparison_axis(ax_ratio, **comparison_options) ax.set(xlabel=None) ax.tick_params(axis="x", labelbottom=False) - - if self.config['save_hist_data']: - self.hist_data = binned_data if comparison_options is not None: return ax, ax_ratio diff --git a/quickstats/utils/common_utils.py b/quickstats/utils/common_utils.py index ad88fd8c15db5278396254c95bf575dacc4024f5..734ee46ca6b63518575fefe7537bd8fab0cc9267 100644 --- a/quickstats/utils/common_utils.py +++ b/quickstats/utils/common_utils.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Dict, List, Tuple, Callable +from typing import Optional, Union, Dict, List, Tuple, Callable, Any import os import sys import copy @@ -13,8 +13,6 @@ import collections.abc import numpy as np -from .string_utils import split_str, parse_as_dict - class disable_cout: def __enter__(self): import cppyy @@ -427,6 +425,7 @@ def filter_dataframe_by_index_values(df, index_values:Union[Tuple[List], List], return df def parse_config_dict(expr:Optional[Union[str, Dict]]=None): + from .string_utils import parse_as_dict if expr is None: return {} if isinstance(expr, str): @@ -453,10 +452,29 @@ def list_of_dict_to_dict_of_list(source:List[Dict], use_first_keys:bool=True): def dict_of_list_to_list_of_dict(source:Dict[str, List]): return [dict(zip(source, t)) for t in zip(*source.values())] -def save_as_json(data:Dict, outname:str, +def save_json(data: Dict, outname: str, indent: int = 2, truncate: bool = False) -> None: + """ + Serializes a dictionary to a JSON file. + + Parameters: + data (Dict): The dictionary object to serialize to JSON. + outname (str): The file path where the JSON output will be saved. + indent (int): The number of spaces to use for indentation in the JSON file. Default is 2. + truncate (bool): If True, the file will be truncated at the end of the JSON data. Default is False. + Typically not needed unless dealing with file updates where the new data might + be shorter than the old data. + """ + with open(outname, "w") as file: + json.dump(data, file, indent=indent) + # truncate the file if the flag is True; this might be useful in case the new JSON data is shorter + # than any existing data in the file to prevent old data from remaining at the end of the file. + if truncate: + file.truncate() + +def save_json(data:Dict, outname:str, indent:int=2, truncate:bool=True): with open(outname, "w") as file: - json.dump(data, file, indent=2) + json.dump(data, file, indent=indent) if truncate: file.truncate() @@ -476,14 +494,39 @@ def filter_dataframe_by_column_values(df:"pd.DataFrame", attributes:Dict): else: df = df[df[attribute] == value] df = df.reset_index(drop=True) - return df + return df class IndentDumper(yaml.Dumper): - def increase_indent(self, flow=False, indentless=False): - return super(IndentDumper, self).increase_indent(flow, False) + """ + A custom YAML Dumper that allows for increased indentation control. + """ -def save_yaml(obj, filename:str, indent:int=2): + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + super().increase_indent(flow, False) + +def save_yaml(obj: Any, filename: str, indent: int = 2) -> None: + """ + Saves a Python object to a YAML file with custom indentation. + + Parameters: + obj (Any): The Python object to serialize and save to YAML. + filename (str): The path to the file where the YAML output should be written. + indent (int): The number of spaces to use for indentation. Default is 2. + """ with open(filename, 'w') as f: yaml.dump(obj, f, Dumper=IndentDumper, default_flow_style=False, - sort_keys=False, indent=indent) \ No newline at end of file + sort_keys=False, indent=indent) + +def remove_duplicates(lst): + """ + Removes duplicates from a list while preserving the original order of elements. + + Parameters: + lst (list): The list from which duplicates are to be removed. + + Returns: + list: A new list containing the unique elements of the original list in the order they first appeared. + """ + seen = set() + return [x for x in lst if not (x in seen or seen.add(x))] \ No newline at end of file diff --git a/quickstats/utils/data_conversion.py b/quickstats/utils/data_conversion.py index 257ae50aa4f9cb4d9fb30c0eab753cb98fd5eaf3..e2a62cc330aaac4e8651356cd35de3bd8cfd03f4 100644 --- a/quickstats/utils/data_conversion.py +++ b/quickstats/utils/data_conversion.py @@ -16,7 +16,7 @@ root_datatypes = ["bool", "Bool_t", "Byte_t", "char", "char*", "Char_t", "UInt_t", "ULong64_t", "ULong_t", "unsigned", "unsigned char", "unsigned int", "unsigned long", "unsigned long long", - "unsigned short", "UShort_t"] + "unsigned short", "UShort_t", "ROOT::VecOps::RVec<Char_t>"] uproot_datatypes = ["bool", "double", "float", "int", "int8_t", "int64_t", "char*", "int32_t", "uint64_t", "uint32_t"] diff --git a/quickstats/utils/path_utils.py b/quickstats/utils/path_utils.py index 2a3e5f20ff87d9c061de87fdcbacb6b42f8a2634..ff23b69b36c2e4abaf2259dc30aa0150eea35a87 100644 --- a/quickstats/utils/path_utils.py +++ b/quickstats/utils/path_utils.py @@ -28,40 +28,23 @@ def is_remote_path(path:str): def is_xrootd_path(path:str): return "root://" in path -def remote_glob(path:str): - # can only glob xrootd path - if not is_xrootd_path(path): - return path - import XRootD.client.glob_funcs as glob - return glob.glob(path) +def remote_file_exist(path:str, timeout:int=0): + from quickstats.interface.xrootd.path import exists + return exists(path, timeout=timeout) -def get_filesystem(host:str): - if host in FILESYSTEM_TO: - return FILESYSTEM_TO[host] - from XRootD.client import FileSystem - FILESYSTEM_TO[host] = FileSystem(host) - return get_filesystem(host) +def remote_glob(path:str): + from quickstats.interface.xrootd.path import glob as remote_glob + return remote_glob(path) -def remote_isdir(dirname:str, timeout:int=0): - # can only list xrootd dir - if not is_xrootd_path(dirname): - return None - from XRootD.client import FileSystem - host, path = split_url(dirname) - query = get_filesystem(host) - if not query: - raise RuntimeError("Cannot prepare xrootd query") - status, dirlist = query.dirlist(path, timeout=timeout) - return not status.error - #return len(remote_glob(os.path.join(dirname, "*"))) > 0 +def remote_isdir(dirname:str, timeout:int=0): + from quickstats.interface.xrootd.path import isdir + return isdir(dirname, timeout=timeout) -def remote_dirlist(dirname:str): - # can only list xrootd dir - if not is_xrootd_path(dirname): - return [] +def remote_listdir(dirname:str): + from quickstats.interface.xrootd.path import glob as remote_glob return remote_glob(os.path.join(dirname, "*")) -def dirlist(dirname:str): +def listdir(dirname:str): return glob.glob(os.path.join(dirname, "*")) def local_file_exist(path:str): @@ -71,18 +54,6 @@ def local_file_exist(path:str): host, path = split_url(path) return local_file_exist(path) return False - -def remote_file_exist(path:str, timeout:int=0): - # can not stat non-xrootd file for now - if not is_xrootd_path(path): - return None - from XRootD.client import FileSystem - host, path = split_url(path) - query = get_filesystem(host) - if not query: - raise RuntimeError("Cannot prepare xrootd query") - status, _ = query.stat(path, timeout=timeout) - return not status.error def resolve_paths(paths:Union[str, List[str]], sep:str=","): @@ -93,11 +64,11 @@ def resolve_paths(paths:Union[str, List[str]], for path in paths: if "*" in path: if is_remote_path(path): - glob_paths = remote_glob(path) + from quickstats.interface.xrootd.path import glob + glob_paths = glob(path) else: glob_paths = glob.glob(path) resolved_paths.extend(glob_paths) else: resolved_paths.append(path) - return resolved_paths - + return resolved_paths \ No newline at end of file diff --git a/quickstats/utils/roofit_utils.py b/quickstats/utils/roofit_utils.py index f03424c7a07865bc092118714093549dbf59be70..8c41773ac55a3844ea45e1497e6b248695462f78 100644 --- a/quickstats/utils/roofit_utils.py +++ b/quickstats/utils/roofit_utils.py @@ -7,6 +7,7 @@ import numpy as np import ROOT +from quickstats import root_version from .string_utils import remove_whitespace, split_str def copy_attributes(source:"ROOT.RooAbsArg", target:"ROOT.RooAbsArg"): @@ -384,17 +385,26 @@ def get_gaus_response_variations(nuis:ROOT.RooRealVar, client:ROOT.RooAddition): value = round(magnitude * beta, 8) return {"nominal": nominal, "low": value, "high": value, "type": "gaus"} -def get_logn_response_variations(nuis:ROOT.RooRealVar, client:ROOT.RooFormulaVar): +def _get_formula_str(formula_var:"ROOT.RooFormulaVar"): + if root_version > (6, 26, 0): + return formula_var.expression() + return formula_var.formula().formulaString() + +def _get_formula_dependents(formula_var:"ROOT.RooFormulaVar"): + if root_version > (6, 26, 0): + return formula_var.dependents() + return formula_var.formula().actualDependents() + +def get_logn_response_variations(nuis:"ROOT.RooRealVar", client:"ROOT.RooFormulaVar"): result = {"nominal": None, "low": None, "high": None, "type": None} if not isinstance(client, ROOT.RooFormulaVar): raise ValueError("lognormal response function must be an instance of RooFormulaVar") nuis_name = nuis.GetName() - formula = client.formula() - formula_str = formula.formulaString() + formula_str = _get_formula_str(client) formula_str = remove_whitespace(formula_str) if not formula_str.startswith("exp("): return result - dependents = formula.actualDependents() + dependents = _get_formula_dependents(client) if dependents.size() != 2: return result beta_term, nuis_term, resp_term = None, None, None @@ -406,10 +416,10 @@ def get_logn_response_variations(nuis:ROOT.RooRealVar, client:ROOT.RooFormulaVar if any(term is None for term in [beta_term, nuis_term, resp_term]): return result beta = beta_term.getVal() - resp_formula_str = resp_term.formula().formulaString() + resp_formula_str = _get_formula_str(resp_term) resp_formula_str = remove_whitespace(resp_formula_str) if resp_formula_str == "log(1+x[0]/x[1])": - resp_dependents = resp_term.formula().actualDependents() + resp_dependents = _get_formula_dependents(resp_term) magnitude = resp_dependents[0].getVal() value = round(magnitude * beta, 8) nominal = resp_dependents[1].getVal() diff --git a/quickstats/utils/root_utils.py b/quickstats/utils/root_utils.py index 058842c9152aeae23e1adbe3376d4116052c98a3..0bd0c4cd389fde80a65be16ba2a4c6927510f2f7 100644 --- a/quickstats/utils/root_utils.py +++ b/quickstats/utils/root_utils.py @@ -7,6 +7,7 @@ import numpy as np import ROOT import quickstats +from .common_utils import get_cpu_count root_type_str_maps = { 'Char_t' : 'char', @@ -39,7 +40,7 @@ def templated_rdf_snapshot(rdf:ROOT.RDataFrame, columns:List[str]=None): def is_corrupt(f:Union[ROOT.TFile, str]): if isinstance(f, str): - f = ROOT.TFile(f) + f = ROOT.TFile.Open(f) if f.IsZombie(): return True if f.TestBit(ROOT.TFile.kRecovered): @@ -378,4 +379,45 @@ def get_cachedir(): cachedir = ROOT.TFile.GetCacheFileDir() if (not cachedir) or (cachedir == "/"): return None - return cachedir \ No newline at end of file + return cachedir + +def set_multithread(num_threads:Union[int, bool]=None): + if num_threads: + if num_threads > 1: + ROOT.EnableImplicitMT(num_threads) + else: + ROOT.EnableImplicitMT() + num_threads = get_cpu_count() + else: + num_threads = None + if ROOT.IsImplicitMTEnabled(): + ROOT.DisableImplicitMT() + return num_threads + +def get_tree_perf_stats(tree): + # https://eguiraud.web.cern.ch/eguiraud/decks/root_io_perf_tooling/#/6 + ps = ROOT.TTreePerfStats("ioperf", tree) + for i in range(tree.GetEntriesFast()): + tree.GetEntry(i) + ps.Print() # or ps.GetXXX(), or ps.Draw() + +def print_tree_clusters(tree): + tree.Print("clusters") + +def set_task_per_worker_hint(m:int): + ROOT.TTreeProcessorMT.SetTasksPerWorkerHint(m) + +def get_task_per_worker_hint(): + return ROOT.TTreeProcessorMT.GetTasksPerWorkerHint() + +def get_opt_flag(): + return ROOT.gSystem.GetFlagsOpt() + +def get_misc_summary(): + summary = { + 'opt_flag': get_opt_flag(), + 'multithread': ROOT.IsImplicitMTEnabled(), + 'task_per_worker_hint': get_task_per_worker_hint(), + 'cachedir': get_cachedir() + } + return summary \ No newline at end of file diff --git a/quickstats/utils/string_utils.py b/quickstats/utils/string_utils.py index e2071f0d4eb26f21912d4cf5c8123b8caba29d26..976445047fbfbff364f1c8773b97336a35570912 100644 --- a/quickstats/utils/string_utils.py +++ b/quickstats/utils/string_utils.py @@ -90,12 +90,7 @@ def split_str(s: str, sep: str = None, strip: bool = True, remove_empty: bool = items = [cast(item) if item else empty_value for item in items] return items - -def split_str_excl_paranthesis(s: str, sep: str = ",", strip: bool = True, remove_empty: bool = False) -> List: - regex = re.compile(sep + r'\s*(?![^()]*\))') - - whitespace_trans = str.maketrans('', '', " \t\r\n\v") newline_trans = str.maketrans('', '', "\r\n") @@ -347,4 +342,69 @@ def format_dict_to_string(dictionary: Dict[str, str], separator: str = " : ", line = f"{' ' * left_margin}{key:{max_key_length}}{separator}{wrapped_value}" formatted_lines.append(line) - return "\n".join(formatted_lines) + "\n" \ No newline at end of file + return "\n".join(formatted_lines) + "\n" + + +def str_to_bool(s:str) -> bool: + """ + Convert a string into a boolean value. + + Parameters: + s (str): The string to convert. + + Returns: + bool: The boolean value of the string. + + Raises: + ValueError: If the string does not represent a boolean value. + """ + s = s.strip().lower() + + true_values = {'true', '1'} + false_values = {'false', '0'} + + if s in true_values: + return True + elif s in false_values: + return False + else: + raise ValueError(f"Invalid literal for boolean: '{s}'") + +def remove_cpp_type_casts(expression: str) -> str: + """ + Removes type casts from a C++ expression based on general structure. + + Parameters: + expression (str): A string containing a C++ expression. + + Returns: + str: The expression with type casts removed. + """ + # Matches a parenthetical that seems like a type (any word potentially followed by pointer/reference symbols), + # ensuring it's not preceded by an identifier character and is followed by a valid variable name. + type_cast_pattern = r'(?<![\w_])\(\s*[a-zA-Z_]\w*\s*[\*&]*\s*\)\s*(?=[a-zA-Z_]\w*|[+-]?\s*\d|\.)' + return re.sub(type_cast_pattern, '', expression) + +def extract_variable_names(expression:str)->List[str]: + """ + Extracts variable names from a C++ expression. + + Parameters: + expression (str): A string containing a C++ expression. + + Returns: + list: A list of unique variable names found in the expression. + """ + + expression = remove_cpp_type_casts(expression) + + # Match potential variable names which are not directly followed by a '(' which would indicate a + # function call. Use negative lookaheads and positive lookbehinds to refine the match. + pattern = r'\b[a-zA-Z_]\w*(?:\.\w+)*\b(?!\s*\()' + + matches = re.findall(pattern, expression) + + from quickstats.utils.common_utils import remove_duplicates + unique_matches = remove_duplicates(matches) + + return unique_matches \ No newline at end of file diff --git a/quickstats/utils/sys_utils.py b/quickstats/utils/sys_utils.py index 871da7add531f19ca8ed465d75b514a2d418ef9a..9da786432bd15edd6df291d79fa6c936fb194e79 100644 --- a/quickstats/utils/sys_utils.py +++ b/quickstats/utils/sys_utils.py @@ -41,4 +41,34 @@ def set_argv(cmd: str, expandvars:bool=True): # Use shlex.split to correctly parse the command line string into arguments, # handling cases with quotes and escaped characters appropriately. parsed_args = shlex.split(cmd) - sys.argv = parsed_args \ No newline at end of file + sys.argv = parsed_args + +def bytes_to_readable(size_in_bytes, digits=2): + """ + Convert the number of bytes to a human-readable string format. + + Parameters: + size_in_bytes (int): The size in bytes that you want to convert. + digits (int, optional): The number of decimal places to format the output. Default is 2. + + Returns: + str: A string representing the human-readable format of the size. + + Examples: + >>> bytes_to_readable(123456789) + '117.74 MB' + + >>> bytes_to_readable(9876543210) + '9.20 GB' + + >>> bytes_to_readable(123456789, digits=4) + '117.7383 MB' + + >>> bytes_to_readable(999, digits=1) + '999.0 B' + """ + for unit in ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']: + if abs(size_in_bytes) < 1024.0: + return f"{size_in_bytes:.{digits}f} {unit}" + size_in_bytes /= 1024.0 + return f"{size_in_bytes:.{digits}f} YB" \ No newline at end of file diff --git a/tutorials/VariableDistributionPlot.ipynb b/tutorials/VariableDistributionPlot.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..79846f8723dab1812534ad0586a82a9f40bf3e7f --- /dev/null +++ b/tutorials/VariableDistributionPlot.ipynb @@ -0,0 +1,188 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "d8299c75-7b71-428d-bfbb-708dd1e6889a", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "# Define the number of data points\n", + "n = 10000\n", + "\n", + "# Generate random data for each distribution\n", + "data_uniform = np.random.uniform(low=0, high=100, size=n)\n", + "data_gaussian = np.random.normal(loc=50, scale=10, size=n)\n", + "\n", + "data_poisson = np.random.poisson(lam=50, size=n)\n", + "\n", + "# Create DataFrames\n", + "df_uniform = pd.DataFrame(data_uniform, columns=['x'])\n", + "df_gaussian = pd.DataFrame(data_gaussian, columns=['x'])\n", + "df_poisson = pd.DataFrame(data_poisson, columns=['x'])\n", + "\n", + "# Create a dictionary of the DataFrames\n", + "dfs = {\n", + " 'uniform': df_uniform,\n", + " 'gaussian': df_gaussian,\n", + " 'poisson': df_poisson\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cd99a2e4-4b56-4db4-82cc-6789c70db955", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "from quickstats.plots import VariableDistributionPlot\n", + "plot_options = {\n", + " 'uniform': {\n", + " # hide with custom function\n", + " 'hide': lambda x: (x < 10) | ((x > 40) & ( x < 50)) | (x > 80),\n", + " 'plot_format': 'hist',\n", + " 'error_format': 'fill'\n", + " },\n", + " 'gaussian': {\n", + " 'error_format': 'errorbar'\n", + " },\n", + " 'poisson': {\n", + " # hide range\n", + " 'hide': (50, 60),\n", + " 'error_format': 'shade'\n", + " }\n", + "}\n", + "plotter = VariableDistributionPlot(dfs, plot_options=plot_options)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0a6822c1-80fa-4cb4-8c92-fdbcb3816ca6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[INFO] Using deduced bin range (0.008, 99.992)\n", + "Welcome to JupyROOT 6.30/04\n" + ] + }, + { + "data": { + "text/plain": [ + "<Axes: ylabel='Fraction of Events / 4.00'>" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/plain": [ + "<Figure size 640x480 with 0 Axes>" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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5HK5PPu99dZbMgxDa9as8Rwn6z6uQYMGatiwYbZyFi5cqD59+qhq1apq0KCBBg8erC1btmTbJuuY69ev13//+1/Vrl1bQ4YMyRbbgQMH9NRTT+mKK65Q1apVNXDgQO3YsUPJycmaPHmy2rRpo7CwMHXq1EnfffedJZ9ffjgcjjzvb7255LBQr732mm6++Wa9/PLL+uqrr7Rq1ao8t23SpIluv/12TZo0SZGRkb6fRSlGJ28AAAD/9frrr+vf//63evfurfbt2+uzzz7TP/7xD9lsNj366KO57nPXXXdpxYoV+vHHH3XXXXepQYMGnnWTJ0/WzJkzVaNGDfXp00dJSUlauXKlvvjiC33wwQeeJCLLvHnz9Oabb+raa6/Vtddem23dX//6V23evFnXXnutTNPUZ599pl9++UVNmzbVtm3b1KdPH1WsWFE//PCDbrrpJv3+++/Fcq/tcDjyfFDuLcnI10ze/fr1U79+/eR0OrV7924dPnxYSUlJysjIUI0aNVSzZk3VqlVLVatWLVDwAAAAKD5nbuta0iHky5k1KxT54TpLjvWf//xH69evV4cOHSRJe/bsUdu2bbV48eI8E4zp06fLZrPpxx9/1BNPPKH69etLkn7++We98MILuuKKK7RixQpVq1ZNkrRlyxb17t1bDz30kG644YZsrXneeecdff/99zn6eEjufh7bt29XZGSkXC6XoqOj9dtvv8lms2n79u2qXr26JGnQoEFatmyZfvzxR11//fWWfC5FwafhoQICAtSqVStdd911uuWWWzRs2DD17t1brVq1IrkAAACA35owYYInuZCkZs2aqV27djp58qTPx3r99dflcrn0/PPPe5ILSWrfvr0eeOABHTx4UMuXL8+2z/Dhw3NNLiR3bUhWjYTNZtOAAQMkSY888ognuZCk/v37S5JOnz7tc8zFybfxZwEAAIBS6Oqrr86xLDQ0tEDH2rNnj4KCgtStW7cc63r37i1J+u2337Itb9WqVZ7Ha9asWbb3WUlL1tQPhY23uOWriZSvxowZo7Vr1+b4YAEAAFDyIj9cp3OvPOX382CE3f9YgfbNrW/sxTUBhXXkyBHVqFEj17niatd2f6aHDh3KtrxWrVp5Hi8gIMCn5f6uSGowjh8/rv379xfFoQEAAACvcmtCdKmRj3xRp04dnThxItsIVFmOH3fPjp6VaGTxdeLq0qxIzvSRRx7R/Pnzi+LQAAAAgEdqamq297t27dK5c+eKtMymTZsqLS1N69evz7Hum2++kZSz2VN5UiQJxrXXXqu77rqrKA4NAAAAeAYYWrFihWdZWlpaniNCWWnChAkyDEMPP/ywzpw541keExOj//znP6pbt65uuOGGIo/DXxVJHwwAAACgKA0ZMkSzZs3SsGHDNHz4cFWpUkVffvmlXC6XOnfuXKRlt2/fXhMnTtTs2bPVsmVL9ezZU0lJSfrmm2/kcrk0f/58hYSEFGkM/sySBGPKlCnavHmzvvrqKysOBwAAgCJmq1ZLrpPHSjqMPNmq5d0pWpKuueYaffTRR/rXv/6lhQsXqkGDBurZs6emT5+uESNG6MKFC0Ua36xZs9SxY0fNnTtXq1atUlhYmK677jo5HA5dfvnlRVq2vzNMC6agvuGGG/Tll1/K6XRaEVMOTqdTzz77rBYuXKi9e/eqTp06uueee/TII4/ka/8ff/xR06ZN0/bt25WUlKRmzZppzJgxuueee3L0zk9JSdHjjz+uzz77TAcOHFDjxo01efLkPJt8ZXUYYiZvAAAAlBfe7oG91mDMmTMnXwUcPHhQkjR37txshdx99935DtKbCRMmaN68eWrQoIEGDx6sDRs2aPLkyUpNTdXUqVO97rtixQoNGDBAFSpUULdu3VS9enV99913uv/++7Vp06ZsndFN09SgQYP01VdfqWXLlrrpppu0evVqjRo1SjabTSNGjLDkfAAAAICyymsNhs1myzakl2ma+RriK2s7K2o01q1bp27duqlHjx5atWqV7Ha70tPT1adPH3333Xf66aefss3K+GctW7bUwYMHtWHDBrVp00aSdOHCBfXr109r165VTEyM2rdvL0l69913NXLkSA0fPlzvvfeeJCkhIUGdO3fWvn37dPDgQdWsWTPb8anBAAAAQHlT4BqMd955Rw888IASEhLUvHlz3X777bkmGO+++65+//13TZs2zaKQ/5BVwzBr1izZ7XZJkt1u1+zZs9WhQwctXrw4zwTj1KlT2rVrl+68805PciFJFSpU0AMPPKC1a9dq8+bNngRj/vz5stlsevHFFz3bhoeHa8aMGbr55pv16aefasKECZafIwAAAFBWeE0w7rzzTnXr1k0jRozQunXrtH37dv3vf/9TZGRktu02btyo33//XY8//rjlAa5fv15RUVGeJCBL+/btFRUVpaVLl2rGjBm57nv69Gl17dpVPXr0yLEuODhY0h+TnrhcLm3cuFGdO3f2TM+epW/fvgoKCtLSpUtJMAAAAAAvLjkPRr169bRmzRo9+eSTWrZsmdq2bauVK1cWR2zKyMjQ3r171bx581zXR0dHKzY2Ns/9o6OjtXbt2hx9QUzT1DvvvCNJ6tWrlyQpNjZWycnJuZYVFhamOnXqeC0LAAAAQD4n2jMMQ1OnTtX69esVGhqq/v3766GHHsoxc6LVEhMT5XK5FBERkev6yMhInT9/XikpKfk63r///W/dcccdql+/vj777DPNnz9fjRo1kiSdPXtWkryWderUqTyPbRhGgV8AAABAcSuq+1efZvLu2LGjtm7dqrFjx+o///mPrrjiCm3durUw5+VVVuIQFhaW6/qs5fHx8fk63tKlS/XBBx/o0KFDCg8Pz/bh5Kesi2dqBAAAAJCTTwmGJIWGhmrOnDn6+OOPdeLECXXu3FmbN28uitg8fT2SkpJyXZ+QkCBJqlKlSr6Ot3btWp07d07r169XgwYNNHr0aC1YsCDfZeVVuyG5m10V9AUAAAAUt6K6f/U5wcgyePBgbdu2TT169PDadKgwQkJCVKlSpTxrDuLj41W5cmWFhobm+5gVK1ZUly5d9PHHH8swDM9cH1nDz3orq1Yt7zNKAgAAAOVdgRMMSapVq5a+/PJLnT17Nt/NlHwVFRWlXbt25ciUTNPU7t27FRUVlee+n3zyie6++27t3Lkzx7o6deqoZs2aiouLk+SuwahQoUKu2yYmJurIkSNeywIAAABQyAQjS+XKlVW5cmUrDpXDwIEDFRcXp5iYmGzLY2JiFBcXpwEDBuS5b3JysubOnavvv/8+x7rz58/r5MmTatq0qWfZgAEDtHnzZp04cSLbtqtWrVJGRobXsgAAAMqrhuPnquH4uSUdBvyEJQlGURo9erQkadKkSUpPT5ckpaena9KkSZKksWPH5rlv9+7dFRwcrFmzZmWrYXE6nXr00UfldDp1/fXXZyvL5XJp4sSJnmUJCQmaOnWqKlSooOHDh1t5agAAAECZ43WiPX/QqlUrTZkyRc8884yio6PVuXNnbdiwQbGxsZo6dapatGjh2fbBBx+UJE2fPl2RkZGqW7euHn/8cU2dOlXNmjVTr169VKFCBf3www/as2ePrr32Wt1zzz2e/fv376/hw4drwYIF2r59u9q0aaPVq1crLi5Or732mtdO3gAAAAAkwywlwxi99NJLWrRokbZt26Z27drp9ttv13333Zdtm6xhZ2NjY1W/fn3P8iVLlmjWrFn69ddflZaWphYtWmj48OH6y1/+ooCAgGzHcDqdeuqpp/T5559r7969uvLKKzV+/HjddtttucaVVWYp+RgBAAAsl9U8av/c8SUcSem1Zs0a9ezZU2+++aZGjRpV0uFckrd74FKTYPgrEgwAAFDekWAU3m+//aYXX3xRI0aMUKdOnUo6nEvydg/stYnUTz/9pCuuuKJoogIAAAAgSWrSpIleeumlkg7DEl47effs2VMNGzbUI488oo0bNxZXTAAAAABKKa8JxsmTJ/XCCy/o+PHjuv7661W3bl1NnDhR3333HU2CAAAAUKJOnjypcePGqUmTJqpbt67uuusuxcXFqVmzZtmmIti+fbvuuOMO1a5dW8HBwapTp44GDhyYYyqDt956S4ZhaM2aNTnKGjNmjKdZUJadO3fqjjvuUP369RUaGqrmzZtr+vTpSklJ8Xm7NWvWyDAMvfXWW9n2Xbp0qXr37q2qVasqJCREjRo10oQJE3Tw4MEc8dntdpmmqZkzZ6pRo0YKDQ1V69atNXdu8Q4h7LWJVIUKFTRkyBANGTJEaWlpWrlypT7++GMNGTJEdrtdQ4YM0S233KJevXrJZvP7EW8BAABQRhw8eFDdu3f3/FunTh1988036tmzp4KCgpSamirJPfhPr169lJCQoD59+uiyyy7T4cOH9eWXX+rrr79WTEyMmjdv7nP5+/btU9euXZWQkKAOHTqoS5cuWrt2rRwOhw4fPqw5c+b4tF1uPv30U918882qVKmSrrvuOlWqVEm//PKL5syZo2+//Va7du1SYGD22/mpU6fqlVde0bXXXiubzabPPvtMd999typXrpznoEVWy/cwtUFBQRowYIAGDBggp9Opr7/+Wh999JGGDx8up9OpwYMHa+jQoerTp4/sdntRxgwAAIBCKKpJ8YriuHl1HJ82bZoOHDig999/X8OGDZMkJSYmqn///vr+++/VpEkTSdJnn32m06dPa+HChdlusBcuXKhhw4ZpxYoVBUow3nvvPcXHx2c7rtPpVIcOHfTuu+/qtddeU0BAQL63y80777yj4OBg/fzzz9lGSP3LX/6i1157Tb/88osuv/xyz/KMjAy999572rJlixo1aiTpj9GpFi9eXGwJRoGqHQICAtSvXz+9/vrrOnbsmBYtWqSQkBCNHz9eNWrU0F133aWlS5daHSsAAACg+Ph4vfPOO7r22ms9yYUkVa5cWc8880y2bbt27arFixdryJAh2ZZnJRUJCQkFiuHMmTOSlO3BekBAgJYtW6aNGzd6uhPkd7vc3HPPPVq6dGm25OJSsf/973/3JBeS1KNHD9WoUUMnT5709RQLrNAT7dlsNvXq1Uu9evXSSy+9pPXr12vx4sWaOHGiBg0aZEWMAAAAgMeePXvkdDrVu3fvHOu6dOmSrUbg8ssv1+WXXy7TNHXgwAHt3btXv/76q95+++1CxXDbbbfppZde0siRIzV8+HD16dNH11xzjerVq6d69er5vF1u+vbtK0lKT0/X77//rr1792rHjh1eR5u6+uqrcywLDQ0t4FkWjOUzeV9zzTW65pprNGvWLKsPDQAAAAtYPV9Fcc+DceDAAUlSzZo1c6wLCAhQ9erVPe+Tk5P1j3/8Q4sXL9bRo0cVERGhNm3a6IorrtCPP/6Y7zL/XNNw9dVXa+PGjZoxY4bee+89T1+Kjh076vHHH9fAgQN92i43+/fv1+TJk7Vy5UolJSWpTp06at++va666ip98sknue5z8bmXFHpmW8QwjDxfDoejpMMDAAAoM2rXri1JiouLy7HO5XLp9OnTnve33367XnnlFY0bN0779u3TmTNntGbNGv3973/3qcyLj5mlY8eO+vjjjz3HnDZtmo4cOaJBgwbp22+/9Xm7i6WkpKhz585au3atZs6cqTNnzujw4cNatmyZBg8enGecfx7pqjAcDkee97fekGBYxDTNPF8kGAAAANaJjo6WJK1evTrHuo0bNyo9PV2SdOHCBa1cuVIDBgzQE088oYYNG3q2O378eJ7Hv3DhQrb3GRkZ2rJlS7Zlr732mqZPny5JCg4OVvfu3eVwOPTmm29Kcncu92W73M7jxIkT+sc//qEJEyYoIiIiX7FbyeFw5Hl/6w0JBgAAAEqVGjVq6KabbtKqVau0aNEiz/Jz585pypQpnvcul0sZGRk6cOCAMjIyPMtPnjypSZMmScqeTFStWlWStGLFimzlPffcczpy5Ei2ZVlDza5atSrb8tjYWEnSZZdd5tN2f5aWliZJ+u2337It37Jli2bOnJkjdn9CggEAAIBS57nnnlO1atV0++23q2fPnhoxYoRatmypjIwM1apVS2FhYQoNDdVNN92krVu36rLLLtPw4cM1aNAgNW7cWM2bN1flypU1Z84cPf3005LcfYlr1qyp2bNna9CgQZoyZYr69OmjGTNm5Bji9d5775XNZtMNN9ygG264QaNGjVLLli11zz33qH79+ho+fLhP2/1Zly5dVK9ePb3yyitq1aqVRo8erV69eumaa67RrbfeKkl66KGH/HLkVhIMAAAAlDrNmjVTTEyMbr31Vu3Zs0c//PCDBg8erBUrViglJUWRkZGSpPnz5+uvf/2rgoKC9Pnnn+vcuXN6/fXXNW/ePD322GMyDENbt26VJEVERGjVqlXq37+/1q9frwULFqhatWpat26dOnTokK38bt26acWKFerevbu2bNmiRYsWyel06sEHH9T69etVo0YNn7b7s0qVKmnVqlUaPHiwTp48qRUrVig8PFxr167Vq6++qltvvVVHjx7V/v37i+5DLiDDvFQjKh/ExcUpMjKyXE20l9XJxcKPEQAAoFQp7lGkJGn9+vWKiIhQy5Ytsy0/fvy4ateurTFjxmjevHnFFk954+0e2OcajDNnzuj555/X+++/71m2Y8cOtWrVSlFRUapcubLuuusupaSkFCJkAAAAIG/33HOPunbtmmOyuRdeeEGSPM2IUPx8qsE4cOCArrnmGh07dkz33nuvXnnlFUlS27Zt9csvv6hGjRoKDAzUsWPHdOutt+qDDz4ossD9BTUYAAAAxe+DDz7QHXfcoYYNG2rYsGEKDAzUhg0b9NVXX+naa6/VihUrFBho+ZRvyOTtHtinBGPMmDF66623dNddd+nhhx9WmzZttH79enXr1k29e/fWihUr5HQ61adPH23YsEG7du1S06ZNrTsTP0SCAQAAUDKWL1+umTNn6pdfflFiYqLq1KmjW265RU899VS5arJfErzdA/uU1q1du1aXXXaZ5s+f7znosmXLZBiG7r77bgUGBiowMFD33Xef1q9fr59++qnMJxgAAAAoGVkjM8G/+NQH49ixY6pfv3622fu+++47SVLv3r09y6KioiRJhw4dsiJGAAAAAKWETwlG3bp1tWXLFs9EJadPn9amTZvUpk0bVa9e3bPdvn37ZBiGZ7ISAAAAAOWDTwnGNddco+TkZM2cOVOnTp3So48+KpfLpcGDB3u2OXbsmP79739Lkpo3b25psAAAAAD8m0+dvPfv36/LL79c586dk+Tu1FGlShX9+uuvql69uv73v/9p4sSJSk1N1dVXX63169cXWeD+gk7eAAAAKG8smwejYcOG2rRpk4YNG6ZWrVrp+uuv18qVKz3No06dOiWbzaahQ4dqyZIlFoQOAAAAoDSxdCbvlJQUhYSEWHW4UoEaDAAAAJQ3ltVgjBs3TrNnz85zfVZycfjwYY0bN07ff/+9L4cHAAAAUMr5lGDMnz9fq1atuuR2cXFxmj9/vjZs2FDgwEobwzDyfDkcjpIODwAAAPCJw+HI8/7Wm0s2kbr22ms9///tt9+qatWqatu2bZ7bu1wu7dq1SydPntQrr7yie++918dTKV1oIgUAAIDyxts98CUTDJvtj0oOwzDyfSPdqFEj/fDDD2V+LgwSDAAAUBrN2/VzSYfg1dgW7Yq9zLfeekujR4/W6tWr1aNHj2IvvzTxdg8ceKmdY2NjPTs3bNhQPXv21Jtvvul1n4CAAEVFRV2y+gQAAADwF82bN9cDDzygOnXqlHQopdolE4x69ep5/n/06NFq27ZttmUAAABAWdCpUyd16tSppMMo9S6ZYFxs3rx5RRUHAAAAgDLAp1GkJCkhIUELFizQk08+6fX11FNPFUW8AAAAgBo2bKi+ffsqPj5eEyZMUNOmTVW7dm0NHjxYv/zyS47tX3nlFXXr1k3h4eFq1qyZhg8frv3792fb5q233pJhGFqzZo1nWXJysv71r3+pdevWqlixourWratbbrlFu3btyrZvfreT3JNTT5gwQa1atVLlypXVuXNnPfHEE8rIyMi2Xa9evdS0aVOlp6drypQpqlu3ripWrKiOHTvq008/LczHV6R8mmjv999/V+/evXX48OFLdmo2DENOp7PQAfo7OnkDAIDSqLR38m7YsKGqVq0ql8ulY8eOqWfPnjp27JjWrl2rkJAQff75556O2rfddpsWLVqkyy67TN26ddPx48e1Zs0aVaxYUStWrNDVV18tKfdO3jfddJOWLl2qOnXqqFu3bvr999/1008/qUaNGtq+fbuqVavm03a//fabunfvrmPHjqlLly5q3LixfvjhB+3Zs0ddunTR6tWrZbfbJbkTjEOHDumqq67S119/rV69euncuXP64osvJEnr1q3zxF7cCtXJ+2JTp07VoUOH1KhRI91zzz2qWbOmNRECAACg2HRb8m5Jh3BJ83dvlyStHTIiz21++ukndezYUTt27FBkZKQkadmyZRo8eLAefvhhbd68WcuWLdOiRYvUv39/LV68WKGhoZKkVatW6YYbbtCkSZO0cePGXI9/6NAhLV26VP3799fnn3/uuameOXOmJk+erJUrV2r48OH53k6S/v73v+vYsWOaP3++Ro8eLUlyOp2aMGGC5s2bp7lz5+ovf/mLJ4bY2FhVrFhRv/zyi6pXry5JevvttzVq1CgtWbKkxBIMb3xKMNatW6ewsDB9//33qlGjRlHFBAAAAOTLv//9b09yIUkDBw7Ubbfdpg8++ECbN2/Wf//7XwUEBOjll1/2JBeS1LdvXw0bNkzvvvuutmzZovbt2+c49pkzZyRJdrs92+io9957r/r27avatWv7tF1cXJw+/vhj9ezZ05NcSO4RWJ9//nktWrRIr732WrYEw+l06umnn/YkF5J06623atSoUTp58mSBPrOi5lMfjISEBLVr147kAgAAACXObrere/fuOZZff/31kqS9e/dqz549atiwoRo1apRju969e0tyN1vKTcuWLdWuXTstXbpUvXv31ksvvaQtW7YoNDQ02z1xfrfbu3evJKlPnz45yqpSpYrat2+fayx/rqUICQnJ8zPxBz7VYLRq1UoHDx4sqlgAAABQDNYOGVHq+2BIUs2aNbNNCp0lax6LQ4cO6ciRI+rYsWOu+2fVLBw6dCjX9Xa7XWvWrNHTTz+tDz74QH/961895Y4dO1b//Oc/FRISku/tjhw5IkmqVatWnvEkJyfrzJkznlqZgIAARUREXPKz8Cc+1WD885//1OHDhzV//vyiigcAAADIlxMnTsjlcuVYfuzYMUnuG/k6dero+PHjue6ftTwr0chNeHi4nnnmGcXGxmr37t363//+p+bNm+vpp5/O1pQpP9tlJT7e4qlQoYKqVKniWVYaJ672KcG44YYb9Pbbb+vhhx/W6NGj9dlnn+mXX37R3r17c30BAAAARSUtLU1r167NsXzlypWSpGbNmqlp06bav39/jiFpJembb77xbJebTZs26R//+Id27twpSYqOjtbdd9+tr7/+WnXr1tWyZct82q5p06bZyr3Y2bNnFRMToyZNmuRaK1Oa+NREKiAgQIZhyDRNvfPOO3rnnXfy3NYwjBxj+QIAAABWeuSRR7Ry5UpPM6LPPvtM77//vtq2baurrrpK9957r1asWKEHH3xQixYt8vRf+PLLL7VgwQJ17NhRHTp0yPXYZ8+e1TPPPKNDhw7pnXfe8dQmnDx5UufPn1e9evV82q5mzZoaPHiwPvnkE73zzjsaOXKkJHdH7kmTJikpKUn33HNP0X1YxcSnBKNHjx6lspoGAAAAZU9kZKROnjypVq1aeebB+O6771ShQgW9+OKLstlsuummmzw39S1atFDXrl11/PhxrV69WhUrVtR//vOfPO9vu3fvrtatW+u9997Ttm3b1KFDBx08eFAbNmzQhQsX9N///ten7STpmWee0YYNG3TXXXfp9ddfV6NGjbLNg1EWEgyZKBRJJh8jAABA8WrQoIHZpEkTMy4uzrzzzjvNevXqmTVr1jQHDRpkbtu2Ldu2LpfLnD17ttmlSxezcuXKZpMmTcw77rjD3LdvX7bt3nzzTVOSuXr1as+yI0eOmPfcc4/ZuHFjs0KFCmZUVJR5/fXXm6tWrcq2b363M03TPH78uDl27FizRYsWZlhYmHnVVVeZTzzxhJmenp5tu549e5qBgYG5nr8kc/To0T59Zlbydg/s00zeyCk/NTrTpk2Tw+Eo+mAAAADKiYYNGyowMJB+v0XI4XBo+vTpXrfJLZUoUILhcrm0ZMkSrVixQkePHtXRo0e1ZcsWrV27VomJibr++usVEBDg62FLJW/TpAMAAKBokGCULG/3wD71wZDc06qPGTNGx44d8xwwq4AtW7Zo0qRJ6tKli5YsWaJq1aoVJm4AAAAApYxPY2Bt27ZNt9xyi+Lj4+VwOLRjxw5de+21nvWDBw/WwIEDtX79eo0aNcryYAEAAAD4N59qMF544QWdO3dOixcv1pAhQyS5ZzjMUq9ePX3yySfq37+/VqxYoa1bt+ryyy+3NGAAAAAgt3kt4B98qsH48ccfVa9ePU9ykZcJEybINE1t3bq1MLEBAAAAKGV8SjDi4uIUFRV1ye0qVqwoSUpISChYVAAAAABKJZ8SjA4dOmj79u1KTEz0ut0PP/wgwzDUrl27QgUHAAAAoHTxKcEYOHCgzp07p9GjRys5OTnXbbZu3arnnntOVatWVceOHS0JEgAAAEDp4FMn77/+9a9auXKlPvnkEzVr1kwDBw7Uvn37JEnPPvustm7dqo8++khOp1MffvihwsLCiiRoAAAAAP7J54n2UlJS9MILL2jmzJm59rG4/PLL9eyzz6pv376WBenPmGgPAAAA5Y23e+ACzeQtSUlJSfr555+1d+9enTp1Sg0bNlSTJk3K3bC0JBgAAAAobyxLMHbu3KmWLVtaF1kZQIIBAACA8sbbPbBPnbxbt26tq666Si+//LJOnTplTXQAAAAAygyfajAaNWqk2NhYGYahwMBA3XDDDbrrrrs0YMCAbDN6lyfUYAAAAKC8sbQPxk8//aQPP/xQixcv1v79+2UYhqpUqaI77rhDI0eOVKdOnayJupQgwQAAAEB5UySdvCV3srFw4UJ99NFHnmSjadOmGjVqlO68807Vq1ev4FGXEiQYAAAAKG+KLMG42ObNm7Vo0SItXLhQhw4dks1mU3p6uhWH9mtZH64306ZNk8PhKPpgAAAAAIs4HA5Nnz7d6zZFlmAkJyfriy++0JIlS/T5558rISFBhmHI6XQW9tB+jxoMAAAAlDfe7oF9msn7YmfOnNGyZcu0ZMkSrVy5UqmpqTJNU1FRURo1apRuu+22gkcMAAAAoFTyKcE4cuSIPvnkEy1ZskTfffednE6nTNNUzZo1dcstt+i2225T165d89VsCAAAAEDZ41MTqYCAAEnuqpDq1atr6NChuu2229SjR49ym1TQRAoAAADljWVNpCIiIjxJRa9evWSz+TRPHwAAAIAyzqcaDKfT6anFgBs1GAAAAChvvN0De62CWLt2rXbs2OF5n9/kYtasWRo3bpwvMQIAAAAoA7wmGD169NDf//73XNc1btw4zyRi1apVevPNNwsdHAAAAIDS5ZKdKPJq+rN//34dP37c8oAAAAAAlF700gYAAABgmVKRYDidTs2YMUPt2rVTaGiomjZtqpkzZ+Z7/23btunWW29VVFSUKlWqpE6dOmnmzJm5zjQeFhYmwzByfb3++utWnhYAAABQ5hR4Ju/iNGHCBM2bN08NGjTQ4MGDtWHDBk2ePFmpqamaOnWq133Xr1+v3r17y+l06sorr1STJk08+69YsUKrVq3y9II/ceKEzp8/r44dO6pz5845jtWqVasiOT8AAACgrPD7BGPdunWaN2+eevTooVWrVslutys9PV19+vTRY489pv79+6tDhw557j958mSlp6dr8eLFuvnmmyVJ6enp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3601a9ZowYIFio6OVpcuXdSkSRNFRETo6NGj+uGHH3TgwAEFBAToww8/VEhISFHFDQAAAMAP+ZRgSNLbb7+tvn37avr06Vq3bp3WrVuXbf2AAQP0r3/9S23aFN2ETAAAAAD8k88T7dlsNo0aNUq///67Dhw4oG+++UYLFy7Uxo0bderUKS1dutTy5MLpdGrGjBlq166dQkND1bRpU82cOTPf+2/btk233nqroqKiVKlSJXXq1EkzZ86U0+nMsW1KSoomT56sFi1aKDQ0VG3atNHbb79t5ekAAAAAZZbPNRhZDMPQZZddpssuuyzHuri4OC1fvly9e/dW/fr1CxWgJE2YMEHz5s1TgwYNNHjwYG3YsEGTJ09Wamqqpk6d6nXf9evXq3fv3nI6nbryyivVpEkTz/4rVqzQqlWrsk11PmjQIH311Vdq2bKlbrrpJq1evVqjRo2SzWbTiBEjCn0uAAAAQFnmcw1GfixYsEDjx4/X4sWLC32sdevWad68eerRo4f27NmjBQsWaM+ePerevbsee+wxxcTEeN1/8uTJSk9P14cffqgNGzbonXfe0a5duzRy5Eh9/fXXmjNnjmfb9957T1999ZWGDx+uHTt26P3339fu3bvVvHlzjRs3TnFxcYU+HwAAAKAs8znBmDVrlpo1a6aAgIA8X4888ogkqUWLFoUOcP78+Z5y7Xa7JMlut2v27NmS5DWJOX36tDZu3Khu3brp5ptv9iy32+166aWXZLPZtHz58mxl2Ww2vfjii55l4eHhmjFjhtLS0vTpp58W+nwAAACAssynBOP999/Xww8/rN9++021atWSYRgyTVO1a9dW/fr1ValSJZmmqYiICD300EPq169foQNcv369oqKicsyp0b59e0VFRWnp0qV57nvgwAGZpqlOnTrlWBceHq4qVapo7969kiSXy6WNGzeqc+fOqlatWrZt+/btq6CgIK9lAQAAAPAxwXj99ddlGIY+//xzHTlyRDExMQoICNDLL7+sffv26cyZM3rhhRd0/vx53XTTTQoMLHAXD0lSRkaG9u7dq+bNm+e6Pjo6WrGxsXnu37hxY61evVr3339/jnU///yzzpw5ozp16kiSYmNjlZycnGtZYWFhqlOnjteyDMMo8AsAAAAobkV1/+pTgpF1s9+/f39JUtu2bdWqVSt9++237oPZbHrooYc0YMAAjR49Wunp6QU8XbfExES5XC5FRETkuj4yMlLnz59XSkpKruvDw8PVo0ePHB3Nz549q/vuu0+SNHz4cM8ySV7LOnXqVEFOAwAAACg3fEowTp8+rVq1amVb1rJlS/3888/Zlg0dOlQHDhzQ5s2bCxVcVuIQFhaW6/qs5fHx8fk+5qZNm9S1a1d9//336tevn2dkqPyUdebMmTyPa5pmgV8AAABAcSuq+1efEoxq1arp0KFD2ZY1a9ZM27ZtU0ZGhmdZeHi4TNPUpk2bfDl8DpGRkZKkpKSkXNcnJCRIkqpUqXLJYyUlJWnChAnq1KmTduzYoWHDhumjjz7yNOPKT1l51W4AAAAAcPMpwejevbt+//33bEO7XnXVVUpISNDHH3/sWZY1t0TlypULFVxISIgqVaqUZ81BfHy8KleurNDQUK/H2bRpky6//HLNmTNHdevW1Ycffqj3338/W21FzZo1JclrWX+uvQEAAACQnU+9sP/2t79p6dKluvfee7V06VItW7ZMffr0UVhYmO6//37t2bNH586d02uvvabAwEB179690AFGRUVp165dMk0zW4cS0zS1e/duRUVFed1/586d6tu3r5KSkvS3v/1NTz75ZK4JSWRkpCpUqKCdO3fmWJeYmKgjR45YMuwuAAAAUJb5VIPRsWNHrVq1SrfccounWVJQUJDmzZun+Ph4Pf7443ruueeUlpamGTNmqFGjRoUOcODAgYqLi8sxoV5MTIzi4uI0YMAAr/uPGDFCSUlJ+vDDD/X88897re0YMGCANm/erBMnTmRbvmrVKmVkZFyyLAAAAKC8M0yLehnv27dPa9euVXJysrp166bWrVtbcVjt2LFDrVu3Vrdu3fT111/LbrcrPT1d1157rdauXaudO3fmWbPwww8/qHPnzho1apTefPPNS5b1+eefa8CAARo2bJjef/99Se6+F506ddKBAwd09OjRHP0wsmpV6KwNoKzqtuRdSdLaISNKdRkAAOt4uwf2qYnUsmXLVL16dXXu3DnHukaNGllSY/FnrVq10pQpU/TMM88oOjpanTt31oYNGxQbG6upU6dmSy4efPBBSdL06dMVGRmpDRs2SJIOHjzoWZdb3JMmTZIk9e/fX8OHD9eCBQu0fft2tWnTRqtXr1ZcXJxee+01OnkDAAAAl+BTDUZISIhat25d6NGhCuKll17SokWL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", + "text/plain": [ + "<Figure size 799.992x599.976 with 1 Axes>" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plotter.draw(\"x\", show_error=True, stacked=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "bee2a551-140a-4c0a-8f07-ff29ff91b0d9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[INFO] Using deduced bin range (0.008, 99.992)\n" + ] + }, + { + "data": { + "text/plain": [ + "<Axes: ylabel='Fraction of Events / 4.00'>" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "text/plain": [ + "<Figure size 640x480 with 0 Axes>" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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i4vLd3GnTp2Sw+FQdHS0x/kxMTE6e/as0tPTFR4enu/95GbOnDk6dOiQ/vWvf+WYl5iYKMnZGtS+fXsNHDhQW7du1dy5c7VkyRL98MMPub737E48+VGSky0AAAD8U0HOX73xGkZWrlyp6OhovfbaawoKuuwowJKktm3basKECXr44Ye1YsUKjRkzJt/FpaenS5LKlSvncX729JSUlEIPIxkZGZoxY4ZuuOEGdejQIcf85ORkVatWTa+99poGDhzomv7888/r4Ycf1rhx47R48eJCrQkAAMAqaZ/MleP4EavLyFVQ5eqKuG10se1vwYIFGjFihL755hvXg74dDofGjh2rjz/+WDVr1tTWrVuLrZ7SymsYOXXqlBo0aJDnIJKtUaNGkuR6EGJ+xcTESJJOnz7tcX5qaqokqWLFigXajyfvvvuuDh06pDfeeMPj/M8//9zj9IceekgffPCBli5dqoyMDI+jjtG6AQAAShvH8SMKqnKF1WXkynHscLHur3Hjxho7dqxq1KjhmrZo0SK9/vrratSoUY5HPJR2BTl/9daq4jWMNG3aVD/++KMOHjyomjVr5nmHn332mQzDUP369fNepQfh4eEqX768kpOTPc5PSUlRVFSUIiIiCrQfT+bPn69KlSqpR48ePq/bvn17bdmyRbt371bz5s0LvTYAAABY65prrtE111zjNm3nzp2SpH/9619q2bKlFWWVOl6bPIYNGya73a727dtr+fLlysrK8rqxffv2acyYMXr77bdVo0YN3XzzzQUuMDY2Vjt27MiRxkzT1M6dOxUbG1vgffzZ7t27tXHjRg0ePNg1nPCf2e12ORwOj/Oy+9ZUqFCh0GsDAABAyZR9vso5YN55DSP333+/xowZo0OHDqlPnz6uloI777xT48aN04QJE3TPPfdo0KBBatq0qerVq6e5c+eqWrVqWrx4caE8GLFPnz5KSkpSQkKC2/SEhAQlJSWpd+/eBd7Hn3344YeSpFtuucXj/MTERIWEhKh///4e52/atElRUVG68sorC702AAAA+Obuu+/O9VahOnXq6LrrrpMkTZ06VYZh6NChQ1qwYIGaNm2q8PBwNWjQQDNmzHC7EL1gwQIZhqG1a9e6tmOz2SRJtWvXVp06ddz288knn6h79+6qVKmSateurf79+2vLli1uy2Rvc8OGDXr99dd1xRVXaMCAAW617d+/X9OmTdPVV1+tSpUqqU+fPtq+fbvS0tI0YcIEtWjRQuXKldM111yj//73v4Xy+RWlyz5n5I033tDAgQP1yiuv6D//+Y9WrVqV67L169fXbbfdpvHjx7v6exTUiBEjNHPmTI0fP16rV692PYF9/PjxkqSRI0cWyn4utWrVKgUHB6tdu3Ye59epU0cdO3bUsmXLtHTpUvXp08c179VXX9V3332nxx57rNDrAgAAQNF788039dxzz6lbt26Kj4/XF198occee0xBQUF65JFHPK4zfPhwrVixQps2bdLw4cNVu3Zt17wJEyZo5syZqlq1qrp3767Tp09r5cqV+vLLL/Xxxx+7Ake2efPm6Z133tH111+v66+/3m3e3/72N/3444+6/vrrZZqmvvjiC23btk0NGjTQ1q1b1b17d0VGRur7779Xv379tGfPnkI7Ly8KeXoC+4033qgbb7xRdrtdO3fu1MGDB3X69GllZWWpatWqqlatmqpXr65KlSoVeoHNmjXTxIkTNWPGDDVq1Ejt2rXTxo0btW/fPk2aNElNmjRxLfvAAw9IcibH/H7oZ8+e1aZNm9SiRQtFRkbmutyLL76oHj16qF+/furatatq1qypbdu2acuWLWrTpo3b09oBAABKs+Rbr7W6hDxJXrtCMf9aX+DtvPTSS9qwYYNat24tSdq1a5datmypRYsW5RpGpk6dqqCgIG3atElPPvmkatWqJUn6+eef9cILL+jqq6/WihUrVLlyZUnSli1b1K1bNz344IPq1auX2x1F7733nr799tscfVIkZ7+UX375RTExMXI4HGrUqJH+97//KSgoSL/88ovrWXd9+/bV0qVLtWnTJt10000F/kyKik/DZAUHB6tZs2bq0aOHbrnlFt1+++3q1q2bmjVrViRBJNszzzyjl156STVr1tTy5csVFxenV199VdOmTXNb7pVXXtErr7yS6+hbebF+/XplZmbm2iqS7eqrr9b333+vQYMG6ddff9Wnn36q0NBQPfnkk/r222+LpFM9AAAAit6YMWNcQUSSGjZsqFatWunYsWM+b+vNN9+Uw+HQ888/7woikhQfH6+xY8fqwIEDWr58uds6Q4cO9RhEJGcrS/ZF96CgIFeXhYcfftjtods9e/aUJJ04ccLnmotTnlpGSoIHHnjA1fKRm8sNOVarVq3LLtOjR488D11Wr149LVy4ME/LAgAAoHRo3759jmn5vdC8a9cuhYWFqVOnTjnmdevWTdOmTdP//vc/t+nNmjXLdXsNGzZ0+zk74GQ/WqOg9Ra3Igkjd999t9atW5fjgwUAAEDpE/Ov9Trz6rQS/5yRcvf7fpu8p4vQl7YwFNShQ4dUtWpVj8/tu+IK5+f5+++/u02vXr16rtsLDg72aXpJ59vTDPPoyJEjSkxMLIpNAwAAAIXG021M3h7S56saNWro6NGjHh8JceSI84n22aEkm68PHC/NiuSdPvzww5o/f35RbBoAAADIl4yMDLefd+zYoTNnzhTpPhs0aKDz589rw4YNOeatWbNGUs5brwJJkYSR66+/XsOHDy+KTQMAAAA+yR5oacWKFa5p58+fz3VkrMI0ZswYGYahhx56SMnJya7pCQkJrgGaevXqVeR1lFSlpgM7AAAAkB8DBgzQrFmzdPvtt2vo0KGqWLGivvrqKzkcjsuOoFpQ8fHxGjdunGbPnq2mTZuqa9euOn36tNasWSOHw6H58+crPDy8SGsoyQoljEycOFE//vij/vOf/xTG5gAAAFDCBFWuLsexw1aXkaugyrl3+u7YsaM+/fRTPf300/rkk09Uu3Ztde3aVVOnTtWwYcN07ty5Iq1t1qxZatOmjebOnatVq1apXLly6tGjh2w2m6666qoi3XdJZ5h5HcfWi169eumrr76S3W4vjJr8Vl46Q02ZMkU2m63oiwEAAAAKic1m09SpU70u4yl2eA0jc+bMydPOX3zxRe3YsUNvvvmm207uueeePK0fKLLDSCHkPwAAAKBU8HYO7DWMBAUFuV3NN00zT1f3s5ejpcQdYQQAAACBxts5sNc+I++9957Gjh2r1NRUNW7cWLfddpvHMPL+++9rz549mjJlSiGVDAAAAMDfXbbPyIEDBzRs2DCtX79eAwcO1FtvvaWYmBi3Zegzkje0jAAAACDQeDsHvuxzRuLi4rR27Vo99dRTWrp0qVq2bKmVK1cWfpUAAAAAAkqeHnpoGIYmTZqkDRs2KCIiQj179tSDDz6Y4ymWAAAAAJBXPj2BvU2bNvrpp580cuRIvfTSS7r66qv1008/FVFpAAAAAPxZvp8z8tlnn2nMmDE6deqUoqKidOLECfqMXAZ9RgAAABBo8j207+UcOXJEd911l1atWsVQvnlAGAEAAECgKbIwku3UqVOSpKioqIJuyq8RRgAAABBoijyMIG8IIwAAAFKd0XMlSYlzR1tcCYpDgYb2BQAAAICiQBgBAAAAYAnCCAAAAFCKrF27VoZhaMGCBVaXUmCEEQsYhpHry2azWV0eAAAASrAaNWpo7Nixaty4sdWluNhstlzPb73x2oF98+bNuvrqqwu92EBFB3YAAAA6sAeafHdg79q1q+rUqaOHH35Y3333XdFUBwAAACAgeQ0jx44d0wsvvKAjR47opptuUs2aNTVu3Dj997//5eo+AAAASo1jx45p1KhRql+/vmrWrKnhw4crKSlJDRs2VIMGDVzL/fLLLxoyZIiuuOIKlSlTRjVq1FCfPn307bffum1vwYIFMgxDa9euzbGvu+++O8ftSb/++quGDBmiWrVqKSIiQo0bN9bUqVOVnp7u83K59RlZsmSJunXrpkqVKik8PFx169bVmDFjdODAgRz1hYaGyjRNzZw5U3Xr1lVERISaN2+uuXPn+vbBFlCIt5lly5bVgAEDNGDAAJ0/f14rV67Uv//9bw0YMEChoaEaMGCAbrnlFl133XUKCqL7CQAAAEqeAwcOqHPnzq4/a9SooTVr1qhr164KCwtTRkaGJGnfvn267rrrlJqaqu7du+vKK6/UwYMH9dVXX2n16tVKSEjIVz+NvXv36tprr1Vqaqpat26tDh06aN26dbLZbDp48KDmzJnj03KefP755xo4cKDKly+vHj16qHz58tq2bZvmzJmjr7/+Wjt27FBIiPup/6RJk/Tqq6/q+uuvV1BQkL744gvdc889ioqK0q233urz+8wPr2HkUmFhYerdu7d69+4tu92u1atX69NPP9XQoUNlt9vVv39/DRo0SN27d1doaGhR1gwAAIBilN3HozRs11M/lClTpmj//v366KOPdPvtt0uSTp06pZ49e+rbb79V/fr1JUlffPGFTpw4oU8++cTtZPyTTz7R7bffrhUrVuQrjHzwwQdKSUlx267dblfr1q31/vvv64033lBwcHCel/PkvffeU5kyZfTzzz+rVq1arul//etf9cYbb2jbtm266qqrXNOzsrL0wQcfaMuWLapbt64kZ4tL165dtWjRomILI/lqzggODtaNN96oN998U4cPH9bChQsVHh6u0aNHq2rVqho+fLiWLFlS2LUCAAAAPklJSdF7772n66+/3hVEJCkqKkozZsxwW/baa6/VokWLNGDAALfp2QEkNTU1XzUkJydLktsF++DgYC1dulTfffedq/tDXpfz5N5779WSJUvcgsjlan/00UddQUSSunTpoqpVq+rYsWO+vsV8y3PLSG6CgoJ03XXX6brrrtPLL7+sDRs2aNGiRRo3bpz69u1bGDUCAAAA+bJr1y7Z7XZ169Ytx7wOHTq4tTRcddVVuuqqq2Sapvbv36/du3frt99+07vvvlugGm699Va9/PLLuvPOOzV06FB1795dHTt2VFxcnOLi4nxezpMbbrhBkpSZmak9e/Zo9+7d2r59u15++eVc12nfvn2OaREREfl8l/lT4DDyZx07dlTHjh01a9aswt40AAAALFDYQ/AW59C++/fvlyRVq1Ytx7zg4GBVqVLF9XNaWpoee+wxLVq0SH/88Yeio6PVokULXX311dq0aVOe9/nnFoz27dvru+++0/Tp0/XBBx+4+n60adNG//jHP9SnTx+flvMkMTFREyZM0MqVK3X69GnVqFFD8fHxatu2rT777DOP61z63q1Cr3MAAAD4rSuuuEKSlJSUlGOew+HQiRMnXD/fdtttevXVVzVq1Cjt3btXycnJWrt2rR599FGf9nnpNrO1adNG//73v13bnDJlig4dOqS+ffvq66+/9nm5S6Wnp6tdu3Zat26dZs6cqeTkZB08eFBLly5V//79c63zcg8kLA6EEQAAAPitRo0aSZK++eabHPO+++47ZWZmSpLOnTunlStXqnfv3nryySdVp04d13JHjhzJdfvnzp1z+zkrK0tbtmxxm/bGG29o6tSpkqQyZcqoc+fOstlseueddyQ5O877spyn93H06FE99thjGjNmjKKjo/NUe0lAGAEAAIDfqlq1qvr166dVq1Zp4cKFrulnzpzRxIkTXT87HA5lZWVp//79ysrKck0/duyYxo8fL8k9eFSqVEmStGLFCrf9Pfvsszp06JDbtOzheVetWuU2fd++fZKkK6+80qfl/uz8+fOSpP/9739u07ds2aKZM2fmqL0kIYwAAADArz377LOqXLmybrvtNnXt2lXDhg1T06ZNlZWVperVq6tcuXKKiIhQv3799NNPP+nKK6/U0KFD1bdvX9WrV0+NGzdWVFSU5syZo2eeeUaSs590tWrVNHv2bPXt21cTJ05U9+7dNX369BzD4v7lL39RUFCQevXqpV69eumuu+5S06ZNde+996pWrVoaOnSoT8v9WYcOHRQXF6dXX31VzZo104gRI3TdddepY8eOGjx4sCTpwQcfLJGj3RJGAAAA4NcaNmyohIQEDR48WLt27dL333+v/v37a8WKFUpPT1dMTIwkaf78+frb3/6msLAwLVu2TGfOnNGbb76pefPmafLkyTIMQz/99JMkKTo6WqtWrVLPnj21YcMGffjhh6pcubLWr1+v1q1bu+2/U6dOWrFihTp37qwtW7Zo4cKFstvteuCBB7RhwwZVrVrVp+X+rHz58lq1apX69++vY8eOacWKFapQoYLWrVun1157TYMHD9Yff/yhxMTEovuQ88kwvQ1Y7KOkpCTFxMTw0MNcZHcSKsSPHAAAoNQpztG0JGnDhg2Kjo5W06ZN3aYfOXJEV1xxhe6++27NmzevWGoJRN7OgX1uGUlOTtbzzz+vjz76yDVt+/btatasmWJjYxUVFaXhw4crPT29ACUDAAAAhePee+/Vtddem+PBfy+88IIkuW5lQvHzqWVk//796tixow4fPqy//OUvevXVVyVJLVu21LZt21S1alWFhITo8OHDGjx4sD7++OMiK7w0ysvwaVOmTJHNZiv6YgAAAALExx9/rCFDhqhOnTq6/fbbFRISoo0bN+o///mPrr/+eq1YsUIhIYX++L2AYrPZXCOB5cZT7PApjNx9991asGCBhg8froceekgtWrTQhg0b1KlTJ3Xr1k0rVqyQ3W5X9+7dtXHjRu3YsUMNGjTw/d34KW7TAgAAsMby5cs1c+ZMbdu2TadOnVKNGjV0yy23aNq0aXQxKGLezoF9ioDr1q3TlVdeqfnz57s2unTpUhmGoXvuuUchISEKCQnRfffdpw0bNmjz5s2EEQAAAFgue4QqlCw+9Rk5fPiwatWq5Xa70X//+19JUrdu3VzTYmNjJUm///57YdQIAAAAwA/5FEZq1qypLVu2uB4Ec+LECf3www9q0aKFqlSp4lpu7969MgzD9TAYAAAAAPgzn8JIx44dlZaWppkzZ+r48eN65JFH5HA41L9/f9cyhw8f1nPPPSdJaty4caEWCwAAAMB/+NSBPTExUVdddZXOnDkjydkJpWLFivrtt99UpUoVvfXWWxo3bpwyMjLUvn17bdiwocgKL43owA4AAIBAU2jPGalTp45++OEH3X777WrW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+koffvih2rRpo9atW3vc9smTJzVjxgxNnz7dbQjbY8eO6ezZs7ryyit9Wq5atWrq37+/1qxZo/fee8+1nN1u1/jx43X69Gnde++9hfPBWMinPiNdunSxpPkne/iy2rVrq3///tq4caMmTJigjIwMTZo0Kc/bSUxM1MqVK3XnnXd6nH/06FGdPXtWbdq0Ubt27XLMb9asWZHUBQAAgKIVExOjY8eOqVmzZq7njPz3v/9V2bJl9eKLLyooKEj9+vVT//799dlnn6lJkya69tprdeTIEX3zzTeKjIzUSy+9lOu5cOfOndW8eXN98MEH2rp1q1q3bq0DBw5o48aNOnfunF5//XWflpOkGTNmaOPGjRo+fLjefPNN1a1b1+05I/4QRmSWcOvWrTMlmV26dDHPnz9vmqZpnj9/3uzcubMpydy8ebPX9R0Oh7lnzx5zwYIFZoMGDUxJ5ujRoz0uu3HjRlOS+corrxRJXZLMUvCRAwAA+JXatWub9evXN5OSksw77rjDjIuLM6tVq2b27dvX3Lp1q9uyDofDnD17ttmhQwczKirKrF+/vjlkyBBz7969bsu98847piTzm2++cU07dOiQee+995r16tUzy5Yta8bGxpo33XSTuWrVKrd187qcaZrmkSNHzJEjR5pNmjQxy5UrZ7Zt29Z88sknzczMTLflunbtaoaEhHh8/5LMESNG+PSZFSZv58A+PYHdCqNGjdK8efOUkJDgNqbzli1b1Lp1az322GOaPn16ruuvXr06x5Boo0eP1pw5c3Is++GHH+qOO+7Q8uXL1bNnz0KviyewAwAAFL86deooJCSEPs0W8XYOnK8eLw6HQ59++qnuuece3Xzzza6T8XXr1mnZsmWy2+0FKNfdhg0bFBsbm+PhMvHx8YqNjdWSJUu8rt+iRQstWrRIixYt0muvveZ12b1790qS6tWrV+R1AQAAAIHOpz4jkrRq1SrdfffdOnz4sCvdZKedLVu2aPz48erQoYMWL16sypUrF6i4rKws7d69W127dvU4v1GjRtq0aZPXbVStWlWDBg2SJO3fv9/rstlh5Msvv9Qtt9yivXv3qm7durrxxhs1depURUZGFlpdAAAAQKDzqWVk69atuuWWW5SSkiKbzabt27fr+uuvd83v37+/+vTpow0bNuiuu+4qcHGnTp2Sw+HI9eEtMTExOnv2rNto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Vqz9RcaMGaN58+apdu3a6t+/vzZu3KgJEyYoIyNDkyZNyvN2EhMTtXLlSt15550e5+/evVtt27ZVamqqWrVqpa5du2rLli2aPn26Pv/8c/3www8KDw93Lb9v3z41bNhQN954Y45ttW/f3vc3CgAAAAQS0wenTp0yr776arN9+/bm7t27fVk139atW2dKMrt06WKeP3/eNE3TPH/+vNm5c2dTkrl582av6zscDnPPnj3mggULzAYNGpiSzNGjR3tcdsiQIaYkc/bs2W7rT5w40ZRkPvbYY67phw8fNiWZDz/8cJ7fiyTTx48cAAAAKNW8nQP71DIyc+ZMdevWTW+88YaaNm2qli1bqnbt2ipTpkyOZQ3D0Pvvv1/AqCTNnz9fkjRr1iyFhoZKkkJDQzV79my1bt1aixYtUuvWrXNdf82aNerevftl92OappYvX664uDiNGzfONd0wDE2bNk3z5s3T8uXLNX36dEnS3r17JUl169bN93sDAAAAAplPYeSpp56SYRiuzicJCQlKSEjwuGxhhZENGzYoNjZW8fHxbtPj4+MVGxurJUuWuAKCJy1atNCiRYskSUePHtV9993ncbmUlBSlpqZ6vOUqODg4R8f97DBSr149n98TAAAAAB/DyDfffFNEZXiWlZWl3bt3q2vXrh7nN2rUSJs2bfK6japVq2rQoEGSpP379+e6XGRkpL755htVr149x7wTJ07o559/dmsFyQ4jmzdv1uTJk7Vjxw7VqFFDnTp10vTp0732qSnIkMgmI3EBAACgmBXVIz18CiOdO3cukiJyc+rUKTkcDkVHR3ucHxMTo7Nnzyo9Pd2tY3l+lClTRl26dMkxPSMjQ/fcc4/sdruGDh3qmp6YmChJmjx5stq3b6+BAwdq69atmjt3rpYsWaIffvhBcXFxBaoJAAAA8Gc+De37Z+fOndOOHTu0YcMGHTp0qLBqcskeTrdcuXIe52dPT0lJKfR9S9Jvv/2mG264QYsXL1bLli318MMPu+YlJyerWrVqWrhwodavX68FCxZoy5Ytmjlzpo4ePerW7+TPTNPM9wsAAAAobkV1/pqvMLJw4UK1bNlSkZGRat68uTp37qy4uDhFREToL3/5i/744498vck/i4mJkSSdPn3a4/zU1FRJKvRnn5w/f16PP/64WrRooXXr1un666/XypUr3ULR559/riNH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AAAAAJYgjAAAAACwBGEEAAAAgCUIIwAAAAAsQRgBAAAAYAnCCAAAAABLEEYsYBhGri+bzWZ1eQAAAIBPbDZbrue33vDQQwuYpml1CQAAAEChsdlsuV5U9xZIaBkBAAAAYAnCCAAAAABLEEYAAAAAWIIwAgAAAMAShBEAAAAAliCMAAAAALBEqQgjdrtd06dPV6tWrRQREaEGDRpo5syZeV7/xIkTuvfee1W/fn1FRkaqbdu2WrZsmdsya9eu9fr8j+zX2rVrXes0a9Ys1+Uee+yxQnv/AAAAgD8qFc8ZGTNmjObNm6fatWurf//+2rhxoyZMmKCMjAxNmjTJ67rp6enq0qWLtm/frjZt2ig+Pl6rV6/WwIEDtWLFCl133XWSpBo1amjs2LG5bmfZsmU6ePCgrrzySte0ffv2qWHDhrrxxhtzLN++fft8vlsAAAAgMJT4MLJ+/XrNmzdPXbp00apVqxQaGqrMzEx1795dkydPVs+ePdW6detc13/++ee1fft2Pf7443r66aclSQcOHFDbtm01ePBgHT58WKGhoapfv75efvllj9v47bff9NZbb+nRRx9V3bp1JUlHjhxRWlqa+vbtq+eee67w3zgAAADg50r8bVrz58+XJM2aNUuhoaGSpNDQUM2ePVuStGjRosuuHxMToyeffNI1LS4uTo8++qhOnDihr7/++rI13H///apdu7YmT57smrZ3715JcoUTAAAAAL4p8WFkw4YNio2NVXx8vNv0+Ph4xcbGasmSJbmue+TIEe3du1c33XSTgoOD3ebdfPPNkuR1fUn68MMPtXr1ar355psqU6aMa3p2GKlXr55P7wcAAACAU4kOI1lZWdq9e7caN27scX6jRo20b9++XNf/9ddfJcnj+g0aNFBQUJDX9TMyMvTII4/opptuUteuXd3mZYeRzZs3q23btipfvrwaN26se+65R8eOHfP+xgAAAACU7DBy6tQpORwORUdHe5wfExOjs2fPKj093eP8kydPSpLH9Q3DUHR0tI4fP57r/ufMmaNDhw7piSeeyDEvMTFRkjR58mSFhYVp4MCBCg8P19y5c9W8eXMdOHAg1+3mZdSu3F4AAABAcSuq89cSHUayQ0a5cuU8zs+enpKSku/1k5OTPc7LyMjQjBkzdMMNN6hDhw455icnJ6tatWpauHCh1q9frwULFmjLli2a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", + "text/plain": [ + "<Figure size 799.992x599.976 with 1 Axes>" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plotter.draw(\"x\", show_error=True, stacked=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09d6ea30-c91c-413b-aacd-b5f6b4fd63d9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}