File indexing completed on 2026-08-04 08:26:58
0001 import gzip
0002 import os
0003 import re
0004
0005 import awkward as ak
0006 import click
0007 import numpy as np
0008 import uproot
0009 from bokeh.events import DocumentReady
0010 from bokeh.io import curdoc
0011 from bokeh.layouts import gridplot
0012 from bokeh.models import ColumnDataSource
0013 from bokeh.models import CustomJSExpr
0014 from bokeh.models import Range1d
0015 from bokeh.models import PrintfTickFormatter
0016 from bokeh.plotting import figure, output_file, save
0017 from hist import Hist
0018 from scipy.stats import PermutationMethod, anderson_ksamp, kstest
0019
0020 from ..util import skip_common_prefix
0021
0022
0023
0024 _AD_MAX_N = 10_000
0025
0026
0027 def _ad_rng(key):
0028 """Return a deterministic RNG seeded from the leaf key.
0029
0030 Using a per-leaf seed (rather than a shared module-level RNG) makes each
0031 leaf's subsample independent of the number and order of previously
0032 processed leaves, so AD p-values stay reproducible when the set of
0033 processed collections changes (e.g. under different match/unmatch
0034 filters).
0035 """
0036 import hashlib
0037 digest = hashlib.blake2b(key.encode("utf-8"), digest_size=8).digest()
0038 return np.random.default_rng(int.from_bytes(digest, "little"))
0039
0040 _MIDPOINT_EXPR_CODE = """
0041 const y1 = this.data.y1;
0042 const y2 = this.data.y2;
0043 return y1.map((v, i) => (v + y2[i]) / 2);
0044 """
0045
0046
0047 def _is_leaf(obj):
0048 """Check if an uproot branch/field object is a leaf (has no sub-branches/sub-fields).
0049
0050 Supports both TTree TBranch objects (which use `.branches`) and
0051 RNTuple RField objects (which use `.fields`).
0052 """
0053 if hasattr(obj, 'branches'):
0054 return len(obj.branches) == 0
0055 if hasattr(obj, 'fields'):
0056 return len(obj.fields) == 0
0057 return True
0058
0059
0060 def _normalize_key(key):
0061 """Normalize uproot key format between TTree and RNTuple styles.
0062
0063 TTree EDM4hep keys follow 'CollectionName/CollectionName.fieldPath' pattern.
0064 RNTuple EDM4hep keys follow 'CollectionName.fieldPath' pattern.
0065 This function converts TTree-style keys to the RNTuple-style format so that
0066 the same physics quantity has the same key regardless of the input file format.
0067
0068 TTree keys for fixed-size array branches include a trailing '[N]' size
0069 annotation (e.g. 'covariance.covariance[21]') which is absent in RNTuple
0070 keys; this suffix is stripped so the two formats match.
0071
0072 >>> _normalize_key('MCParticles/MCParticles.momentum.x')
0073 'MCParticles.momentum.x'
0074 >>> _normalize_key('MCParticles.momentum.x')
0075 'MCParticles.momentum.x'
0076 >>> _normalize_key('EventHeader/EventHeader.eventNumber')
0077 'EventHeader.eventNumber'
0078 >>> _normalize_key('CentralCKFTrackParameters/CentralCKFTrackParameters.covariance.covariance[21]')
0079 'CentralCKFTrackParameters.covariance.covariance'
0080 """
0081 if "/" in key:
0082 _, field_part = key.split("/", 1)
0083 else:
0084 field_part = key
0085 return re.sub(r'\[\d+\]$', '', field_part)
0086
0087
0088 def match_filter(key, match, unmatch):
0089 accept = True
0090 if match:
0091 accept = False
0092 for regex in match:
0093 if regex.match(key):
0094 accept = True
0095 for regex in unmatch:
0096 if regex.match(key):
0097 accept = False
0098 return accept
0099
0100
0101 @click.command()
0102 @click.argument("files", type=click.File('rb'), nargs=-1)
0103 @click.option(
0104 "-m", "--match", multiple=True,
0105 help="Only include collections with names matching a regex"
0106 )
0107 @click.option(
0108 "-M", "--unmatch", multiple=True,
0109 help="Exclude collections with names matching a regex"
0110 )
0111 @click.option(
0112 "--serve", is_flag=True,
0113 default=False,
0114 help="Run a local HTTP server to view the report"
0115 )
0116 def bara(files, match, unmatch, serve):
0117 arr = {}
0118
0119 match = list(map(re.compile, match))
0120 unmatch = list(map(re.compile, unmatch))
0121
0122 for _file in files:
0123 tree = uproot.open(_file)["events"]
0124
0125 sort_by_evtnum = None
0126 for evtnum_key in ["EventHeader/EventHeader.eventNumber", "EventHeader.eventNumber"]:
0127 if evtnum_key in tree.keys(recursive=True):
0128 evtnum = tree[evtnum_key].array()
0129 sort_by_evtnum = ak.argsort(ak.flatten(evtnum))
0130 break
0131
0132 for key in tree.keys(recursive=True):
0133 if not key.startswith("PARAMETERS") and _is_leaf(tree[key]):
0134 normalized = _normalize_key(key)
0135 if match_filter(normalized, match, unmatch):
0136 val = tree[key].array()
0137 if sort_by_evtnum is not None:
0138 val = val[sort_by_evtnum]
0139 arr.setdefault(normalized, {})[_file] = val
0140
0141 paths = skip_common_prefix([_file.name.split("/") for _file in files])
0142 paths = skip_common_prefix([reversed(list(path)) for path in paths])
0143 labels = ["/".join(reversed(list(reversed_path))) for reversed_path in paths]
0144
0145 collection_figs = {}
0146 collection_with_diffs = {}
0147 collection_ks_pvalue = {}
0148 collection_ad_pvalue = {}
0149 collection_matching_count = {}
0150 collection_step_exprs = {}
0151
0152 for key in sorted(arr.keys()):
0153 if any("string" in str(ak.type(a)) for a in arr[key].values()):
0154 click.echo(f"String value detected for key \"{key}\". Skipping...")
0155 continue
0156 if any("bool" in str(ak.type(a)) for a in arr[key].values()):
0157 click.echo(f"Bool value detected for key \"{key}\". Skipping...")
0158 continue
0159 if any(a.layout.minmax_depth[0] < 2 for a in arr[key].values()):
0160
0161 print(f"Skipping non-array branch \"{key}\"")
0162 continue
0163
0164 x_min = min(filter(
0165 lambda v: v is not None,
0166 map(lambda a: ak.min(ak.mask(a, np.isfinite(a))), arr[key].values())
0167 ), default=None)
0168 if x_min is None:
0169 continue
0170 x_range = max(filter(
0171 lambda v: v is not None,
0172 map(lambda a: ak.max(ak.mask(a - x_min, np.isfinite(a))), arr[key].values())
0173 ), default=None)
0174 nbins = 10
0175
0176 if (any("* uint" in str(ak.type(a)) for a in arr[key].values())
0177 or any("* int" in str(ak.type(a)) for a in arr[key].values())):
0178 x_range = x_range + 1
0179 nbins = int(min(100, np.ceil(x_range)))
0180 else:
0181 x_range = x_range * 1.1
0182
0183 if x_range == 0:
0184 x_range = 1
0185
0186 if "." in key:
0187 branch_name = key.split(".", 1)[0]
0188 leaf_name = key
0189 else:
0190 branch_name = key
0191 leaf_name = key
0192
0193 midpoint_expr = collection_step_exprs.setdefault(
0194 branch_name,
0195 CustomJSExpr(code=_MIDPOINT_EXPR_CODE),
0196 )
0197 fig = figure(x_axis_label=leaf_name, y_axis_label="Entries")
0198 if x_range < 1.:
0199 fig.xaxis.formatter = PrintfTickFormatter(format="%.2g")
0200 collection_figs.setdefault(branch_name, []).append(fig)
0201 y_max = 0
0202
0203 prev_file_arr = None
0204 vis_params = [
0205 ("green", 1.5, "solid", " "),
0206 ("red", 3, "dashed", ","),
0207 ("blue", 2, "dotted", "."),
0208 ]
0209
0210 leaf_min_pvalue = 1.0
0211 if set(arr[key].keys()) != set(files):
0212
0213 collection_with_diffs[branch_name] = 0.0
0214 leaf_min_pvalue = 0.0
0215
0216 for _file, label, (color, line_width, line_dash, hatch_pattern) in zip(files, labels, vis_params):
0217 if _file not in arr[key]:
0218 continue
0219 file_arr = arr[key][_file]
0220
0221
0222 pvalue = None
0223 ks_pvalue = None
0224 ad_pvalue = None
0225 if prev_file_arr is not None:
0226 if ((ak.num(file_arr, axis=0) != ak.num(prev_file_arr, axis=0))
0227 or ak.any(ak.num(file_arr, axis=1)
0228 != ak.num(prev_file_arr, axis=1))
0229 or ak.any(ak.nan_to_none(file_arr)
0230 != ak.nan_to_none(prev_file_arr))):
0231 if (ak.num(ak.flatten(file_arr, axis=None), axis=0) > 0 and
0232 ak.num(ak.flatten(prev_file_arr, axis=None), axis=0) > 0):
0233
0234 flat_a = ak.to_numpy(ak.flatten(file_arr, axis=None))
0235 flat_b = ak.to_numpy(ak.flatten(prev_file_arr, axis=None))
0236
0237 if (flat_a.shape == flat_b.shape
0238 and np.array_equal(flat_a, flat_b)):
0239 ks_pvalue = 1.0
0240 ad_pvalue = 1.0
0241 else:
0242 ks_pvalue = kstest(flat_a, flat_b).pvalue
0243
0244
0245
0246
0247
0248
0249 rng = _ad_rng(key)
0250 ad_a, ad_b = flat_a, flat_b
0251 if len(ad_a) > _AD_MAX_N:
0252 ad_a = rng.choice(ad_a, _AD_MAX_N, replace=False)
0253 if len(ad_b) > _AD_MAX_N:
0254 ad_b = rng.choice(ad_b, _AD_MAX_N, replace=False)
0255 try:
0256
0257
0258
0259 ad_result = anderson_ksamp(
0260 [ad_a, ad_b],
0261
0262
0263
0264
0265
0266
0267
0268 method=PermutationMethod(n_resamples=999, batch=200, rng=rng),
0269 variant="midrank",
0270 )
0271 ad_pvalue = float(ad_result.pvalue)
0272 except (ValueError, TypeError):
0273 ad_pvalue = None
0274 if ad_pvalue is None:
0275 pvalue = ks_pvalue
0276 else:
0277 pvalue = min(ks_pvalue, ad_pvalue)
0278 else:
0279 ks_pvalue = 0
0280 ad_pvalue = 0
0281 pvalue = 0
0282 print(key)
0283 print(f"p_KS = {ks_pvalue:.3f}",
0284 f"p_AD = {ad_pvalue:.3f}" if ad_pvalue is not None else "p_AD = n/a")
0285 print(prev_file_arr)
0286 print(file_arr)
0287 collection_with_diffs[branch_name] = min(pvalue, collection_with_diffs.get(branch_name, 1.))
0288 collection_ks_pvalue[branch_name] = min(ks_pvalue, collection_ks_pvalue.get(branch_name, 1.))
0289 if ad_pvalue is not None:
0290 collection_ad_pvalue[branch_name] = min(ad_pvalue, collection_ad_pvalue.get(branch_name, 1.))
0291 leaf_min_pvalue = min(leaf_min_pvalue, pvalue)
0292
0293
0294 h = (
0295 Hist.new
0296 .Reg(nbins, 0, x_range, name="x", label=key)
0297 .Int64()
0298 )
0299 h.fill(x=ak.flatten(file_arr - x_min, axis=None))
0300
0301 ys, edges = h.to_numpy()
0302 y0 = np.concatenate([ys, [ys[-1]]])
0303 legend_parts = [label]
0304 if ks_pvalue is not None:
0305 legend_parts.append(f"{100*ks_pvalue:.0f}%CL KS")
0306 if ad_pvalue is not None:
0307 legend_parts.append(f"{100*ad_pvalue:.0f}%CL AD")
0308 legend_label = "\n".join(legend_parts)
0309 source = ColumnDataSource(
0310 {
0311 "x": edges + x_min,
0312 "y1": y0 - np.sqrt(y0),
0313 "y2": y0 + np.sqrt(y0),
0314 }
0315 )
0316 step_r = fig.step(
0317 x="x",
0318 y={"expr": midpoint_expr},
0319 mode="after",
0320 source=source,
0321 legend_label=legend_label,
0322 line_color=color,
0323 line_width=line_width,
0324 line_dash=line_dash,
0325 )
0326 step_r.nonselection_glyph = step_r.glyph
0327 varea_r = fig.varea_step(
0328 x="x",
0329 y1="y1",
0330 y2="y2",
0331 step_mode="after",
0332 source=source,
0333 legend_label=legend_label,
0334 fill_color=color if hatch_pattern == " " else None,
0335 fill_alpha=0.25,
0336 hatch_color=color,
0337 hatch_alpha=0.5,
0338 hatch_pattern=hatch_pattern,
0339 )
0340 varea_r.nonselection_glyph = varea_r.glyph
0341 fig.legend.background_fill_alpha = 0.5
0342
0343 y_max = max(y_max, np.max(y0 + np.sqrt(y0)))
0344 prev_file_arr = file_arr
0345
0346 if leaf_min_pvalue == 1.0:
0347 collection_matching_count[branch_name] = collection_matching_count.get(branch_name, 0) + 1
0348
0349 x_bounds = (x_min - 0.05 * x_range, x_min + 1.05 * x_range)
0350 y_bounds = (- 0.05 * y_max, 1.05 * y_max)
0351
0352 if np.all(np.isfinite(x_bounds)):
0353 try:
0354 fig.x_range = Range1d(
0355 *x_bounds,
0356 bounds=x_bounds)
0357 except ValueError as e:
0358 click.secho(str(e), fg="red", err=True)
0359 else:
0360 click.secho(f"overflow while calculating x bounds for \"{key}\"", fg="red", err=True)
0361 if np.all(np.isfinite(y_bounds)):
0362 try:
0363 fig.y_range = Range1d(
0364 *y_bounds,
0365 bounds=y_bounds)
0366 except ValueError as e:
0367 click.secho(str(e), fg="red", err=True)
0368 else:
0369 click.secho(f"overflow while calculating y bounds for \"{key}\"", fg="red", err=True)
0370
0371 def to_filename(branch_name):
0372 return branch_name.replace("#", "__pound__").replace("/", "__underscore__")
0373
0374 def option_key(item):
0375 collection_name, figs = item
0376 key = ""
0377 if collection_name in collection_with_diffs:
0378 if collection_with_diffs[collection_name] > 0.99:
0379 key += " 0.99"
0380 elif collection_with_diffs[collection_name] > 0.95:
0381 key += " 0.95"
0382 elif collection_with_diffs[collection_name] > 0.67:
0383 key += " 0.67"
0384 else:
0385 key += " 0.00"
0386 key += collection_name.lstrip("_")
0387 return key
0388
0389 options = [("", "")]
0390 for collection_name, figs in sorted(collection_figs.items(), key=option_key):
0391 marker = ""
0392 if collection_name in collection_with_diffs:
0393 if collection_with_diffs[collection_name] > 0.99:
0394 marker = " (*)"
0395 elif collection_with_diffs[collection_name] > 0.95:
0396 marker = " (**)"
0397 elif collection_with_diffs[collection_name] > 0.67:
0398 marker = " (***)"
0399 else:
0400 marker = " (****)"
0401 options.append((to_filename(collection_name), collection_name + marker))
0402
0403 from bokeh.models import CustomJS, Select, DataTable, TableColumn, HTMLTemplateFormatter, NumberFormatter, StringFormatter
0404 from bokeh.models.comparisons import CustomJSCompare
0405
0406
0407
0408 _ks_sorter = CustomJSCompare(code="""
0409 if (x === '' && y === '') return 0;
0410 if (x === '') return 1;
0411 if (y === '') return -1;
0412 const nx = parseFloat(x), ny = parseFloat(y);
0413 return nx < ny ? -1 : nx > ny ? 1 : 0;
0414 """)
0415
0416 _ad_sorter = CustomJSCompare(code="""
0417 if (x === '' && y === '') return 0;
0418 if (x === '') return 1;
0419 if (y === '') return -1;
0420 if (x === 'n/a' && y === 'n/a') return 0;
0421 if (x === 'n/a') return 1;
0422 if (y === 'n/a') return -1;
0423 const nx = parseFloat(x), ny = parseFloat(y);
0424 return nx < ny ? -1 : nx > ny ? 1 : 0;
0425 """)
0426
0427 def mk_summary_table():
0428 rows = []
0429 for collection_name, figs in sorted(
0430 collection_figs.items(),
0431 key=lambda item: item[0].lstrip("_"),
0432 ):
0433 if collection_name in collection_with_diffs:
0434 pvalue = collection_with_diffs[collection_name]
0435 if pvalue > 0.99:
0436 color = "#28a745"
0437 elif pvalue > 0.95:
0438 color = "#ffc107"
0439 elif pvalue > 0.67:
0440 color = "#fd7e14"
0441 else:
0442 color = "#dc3545"
0443 ks_str = (f"{collection_ks_pvalue[collection_name]:.3f}"
0444 if collection_name in collection_ks_pvalue else "")
0445 ad_str = (f"{collection_ad_pvalue[collection_name]:.3f}"
0446 if collection_name in collection_ad_pvalue else "n/a")
0447 else:
0448 color = "transparent"
0449 ks_str = ""
0450 ad_str = ""
0451 n_total = len(figs)
0452 n_match = collection_matching_count.get(collection_name, 0)
0453 n_diff = n_total - n_match
0454 rows.append((collection_name, color, ks_str, ad_str, n_match, n_diff, n_total))
0455
0456 source = ColumnDataSource({
0457 "collection": [r[0] for r in rows],
0458 "filename": [to_filename(r[0]) for r in rows],
0459 "color": [r[1] for r in rows],
0460 "ks_pvalue": [r[2] for r in rows],
0461 "ad_pvalue": [r[3] for r in rows],
0462 "nmatch": [r[4] for r in rows],
0463 "ndiff": [r[5] for r in rows],
0464 "nplots": [r[6] for r in rows],
0465 })
0466 square_style = (
0467 'display:inline-block;width:0.9em;height:0.9em;'
0468 'margin-right:6px;vertical-align:middle;'
0469 'border:1px solid #999;background-color:<%= color %>;'
0470 )
0471 link_fmt = HTMLTemplateFormatter(
0472 template=f'<span style="{square_style}"></span>'
0473 '<a href="#<%= filename %>"><%= value %></a>'
0474 )
0475 right_str = StringFormatter(text_align="right")
0476 right_num = NumberFormatter(text_align="right")
0477 columns = [
0478 TableColumn(field="collection", title="Collection", formatter=link_fmt, width=500),
0479 TableColumn(field="ks_pvalue", title="min KS p-value", formatter=right_str, width=120, sorter=_ks_sorter),
0480 TableColumn(field="ad_pvalue", title="min AD p-value", formatter=right_str, width=120, sorter=_ad_sorter),
0481 TableColumn(field="nmatch", title="# matching", formatter=right_num, width=80),
0482 TableColumn(field="ndiff", title="# differing", formatter=right_num, width=80),
0483 TableColumn(field="nplots", title="# plots", formatter=right_num, width=80),
0484 ]
0485 table = DataTable(
0486 source=source,
0487 columns=columns,
0488 width=800,
0489 sizing_mode="stretch_height",
0490 index_position=None,
0491 sortable=True,
0492 selectable=True,
0493 )
0494 source.selected.js_on_change("indices", CustomJS(args={"source": source}, code="""
0495 const idx = cb_obj.indices;
0496 if (idx.length > 0) {
0497 const filename = source.data["filename"][idx[0]];
0498 window.location.hash = "#" + filename;
0499 fetchAndReplaceBokehDocument(filename);
0500 }
0501 """))
0502 return table
0503
0504 def mk_dropdown(value=""):
0505 dropdown = Select(title="Select branch (**** < 67% CL, ..., * > 99% CL stat. equiv.):", value=value, options=options)
0506 dropdown.js_on_change("value", CustomJS(code="""
0507 console.log('dropdown: ' + this.value, this.toString())
0508 if (this.value != "") {
0509 window.location.hash = "#" + this.value;
0510 fetchAndReplaceBokehDocument(this.value);
0511 } else {
0512 // Empty option selected: navigate back to the index page.
0513 window.location.hash = "";
0514 }
0515 """))
0516 return dropdown
0517
0518 def mk_dropdown_minimal(value=""):
0519
0520
0521
0522 label = next((lbl for val, lbl in options if val == value), value)
0523 minimal_options = [("", "")] + ([(value, label)] if value else [])
0524 dropdown = Select(title="Select branch (**** < 67% CL, ..., * > 99% CL stat. equiv.):", value=value, options=minimal_options)
0525 dropdown.js_on_change("value", CustomJS(code="""
0526 console.log('dropdown: ' + this.value, this.toString())
0527 if (this.value != "") {
0528 window.location.hash = "#" + this.value;
0529 fetchAndReplaceBokehDocument(this.value);
0530 } else {
0531 // Empty option selected: navigate back to the index page.
0532 window.location.hash = "";
0533 }
0534 """))
0535 return dropdown
0536
0537 from bokeh.layouts import column
0538 from bokeh.embed import json_item
0539 import json
0540
0541 os.makedirs("capybara-reports", exist_ok=True)
0542
0543 for collection_name, figs in collection_figs.items():
0544 item = column(
0545 mk_dropdown_minimal(collection_name),
0546 gridplot(figs, ncols=3, width=400, height=300),
0547 )
0548
0549 with gzip.open(f"capybara-reports/{to_filename(collection_name)}.json.gz", "wt") as fp:
0550 json.dump(json_item(item), fp, separators=(',', ':'))
0551
0552 curdoc().js_on_event(DocumentReady, CustomJS(args={"all_options": options}, code="""
0553 window._bokehSelectOptions = all_options;
0554
0555 function fetchAndReplaceBokehDocument(location) {
0556 fetch(location + '.json.gz')
0557 .then(async function(response) {
0558 if (!response.ok) {
0559 throw new Error('Network response was not ok');
0560 }
0561
0562 const ds = new DecompressionStream('gzip');
0563 const decompressedStream = response.body.pipeThrough(ds);
0564 const decompressedResponse = new Response(decompressedStream);
0565 const item = await decompressedResponse.json();
0566
0567 Bokeh.documents[0].replace_with_json(item.doc);
0568
0569 // Restore the full options list to the newly loaded Select widget.
0570 for (const [, model] of Bokeh.documents[0]._all_models) {
0571 if (model.options instanceof Array) {
0572 model.options = window._bokehSelectOptions;
0573 model.value = location;
0574 break;
0575 }
0576 }
0577 })
0578 .catch(function(error) {
0579 console.error('Fetch or decompression failed:', error);
0580 });
0581 }
0582
0583 window.onhashchange = function() {
0584 var location = window.location.hash.replace(/^#/, "");
0585 if (location == "") {
0586 // No hash: return to the index page. Since there is no index.json.gz,
0587 // just reload the page to get a fresh index.html.
0588 if (typeof window.current_location !== 'undefined') {
0589 window.location.reload();
0590 }
0591 return;
0592 }
0593 if ((typeof current_location === 'undefined') || (current_location != location)) {
0594 fetchAndReplaceBokehDocument(location);
0595 window.current_location = location;
0596 }
0597 }
0598 window.onhashchange();
0599 """))
0600 output_file(filename="capybara-reports/index.html", title="ePIC capybara report")
0601 save(column(
0602 mk_dropdown(),
0603 mk_summary_table(),
0604 sizing_mode="stretch_height",
0605 ))
0606
0607 if serve:
0608 os.chdir("capybara-reports/")
0609 from http.server import SimpleHTTPRequestHandler
0610 from socketserver import TCPServer
0611 with TCPServer(("127.0.0.1", 24535), SimpleHTTPRequestHandler) as httpd:
0612 print("Serving report at http://127.0.0.1:24535")
0613 try:
0614 httpd.serve_forever()
0615 except KeyboardInterrupt:
0616 pass