File indexing completed on 2026-11-02 09:31:14
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 integer_axis_range, 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 nbins = 10
0171
0172 if (any("* uint" in str(ak.type(a)) for a in arr[key].values())
0173 or any("* int" in str(ak.type(a)) for a in arr[key].values())):
0174 x_max = max(filter(
0175 lambda v: v is not None,
0176 map(lambda a: ak.max(ak.mask(a, np.isfinite(a))), arr[key].values())
0177 ), default=None)
0178 x_range = integer_axis_range(x_min, x_max)
0179 nbins = int(min(100, x_range))
0180 else:
0181 x_range = max(filter(
0182 lambda v: v is not None,
0183 map(lambda a: ak.max(ak.mask(a - x_min, np.isfinite(a))), arr[key].values())
0184 ), default=None)
0185 x_range = x_range * 1.1
0186
0187 if x_range == 0:
0188 x_range = 1
0189
0190 if "." in key:
0191 branch_name = key.split(".", 1)[0]
0192 leaf_name = key
0193 else:
0194 branch_name = key
0195 leaf_name = key
0196
0197 midpoint_expr = collection_step_exprs.setdefault(
0198 branch_name,
0199 CustomJSExpr(code=_MIDPOINT_EXPR_CODE),
0200 )
0201 fig = figure(x_axis_label=leaf_name, y_axis_label="Entries")
0202 if x_range < 1.:
0203 fig.xaxis.formatter = PrintfTickFormatter(format="%.2g")
0204 collection_figs.setdefault(branch_name, []).append(fig)
0205 y_max = 0
0206
0207 prev_file_arr = None
0208 vis_params = [
0209 ("green", 1.5, "solid", " "),
0210 ("red", 3, "dashed", ","),
0211 ("blue", 2, "dotted", "."),
0212 ]
0213
0214 leaf_min_pvalue = 1.0
0215 if set(arr[key].keys()) != set(files):
0216
0217 collection_with_diffs[branch_name] = 0.0
0218 leaf_min_pvalue = 0.0
0219
0220 for _file, label, (color, line_width, line_dash, hatch_pattern) in zip(files, labels, vis_params):
0221 if _file not in arr[key]:
0222 continue
0223 file_arr = arr[key][_file]
0224
0225
0226 pvalue = None
0227 ks_pvalue = None
0228 ad_pvalue = None
0229 if prev_file_arr is not None:
0230 if ((ak.num(file_arr, axis=0) != ak.num(prev_file_arr, axis=0))
0231 or ak.any(ak.num(file_arr, axis=1)
0232 != ak.num(prev_file_arr, axis=1))
0233 or ak.any(ak.nan_to_none(file_arr)
0234 != ak.nan_to_none(prev_file_arr))):
0235 if (ak.num(ak.flatten(file_arr, axis=None), axis=0) > 0 and
0236 ak.num(ak.flatten(prev_file_arr, axis=None), axis=0) > 0):
0237
0238 flat_a = ak.to_numpy(ak.flatten(file_arr, axis=None))
0239 flat_b = ak.to_numpy(ak.flatten(prev_file_arr, axis=None))
0240
0241 if (flat_a.shape == flat_b.shape
0242 and np.array_equal(flat_a, flat_b)):
0243 ks_pvalue = 1.0
0244 ad_pvalue = 1.0
0245 else:
0246 ks_pvalue = kstest(flat_a, flat_b).pvalue
0247
0248
0249
0250
0251
0252
0253 rng = _ad_rng(key)
0254 ad_a, ad_b = flat_a, flat_b
0255 if len(ad_a) > _AD_MAX_N:
0256 ad_a = rng.choice(ad_a, _AD_MAX_N, replace=False)
0257 if len(ad_b) > _AD_MAX_N:
0258 ad_b = rng.choice(ad_b, _AD_MAX_N, replace=False)
0259 try:
0260
0261
0262
0263 ad_result = anderson_ksamp(
0264 [ad_a, ad_b],
0265
0266
0267
0268
0269
0270
0271
0272 method=PermutationMethod(n_resamples=999, batch=200, rng=rng),
0273 variant="midrank",
0274 )
0275 ad_pvalue = float(ad_result.pvalue)
0276 except (ValueError, TypeError):
0277 ad_pvalue = None
0278 if ad_pvalue is None:
0279 pvalue = ks_pvalue
0280 else:
0281 pvalue = min(ks_pvalue, ad_pvalue)
0282 else:
0283 ks_pvalue = 0
0284 ad_pvalue = 0
0285 pvalue = 0
0286 print(key)
0287 print(f"p_KS = {ks_pvalue:.3f}",
0288 f"p_AD = {ad_pvalue:.3f}" if ad_pvalue is not None else "p_AD = n/a")
0289 print(prev_file_arr)
0290 print(file_arr)
0291 collection_with_diffs[branch_name] = min(pvalue, collection_with_diffs.get(branch_name, 1.))
0292 collection_ks_pvalue[branch_name] = min(ks_pvalue, collection_ks_pvalue.get(branch_name, 1.))
0293 if ad_pvalue is not None:
0294 collection_ad_pvalue[branch_name] = min(ad_pvalue, collection_ad_pvalue.get(branch_name, 1.))
0295 leaf_min_pvalue = min(leaf_min_pvalue, pvalue)
0296
0297
0298 h = (
0299 Hist.new
0300 .Reg(nbins, 0, x_range, name="x", label=key)
0301 .Int64()
0302 )
0303 h.fill(x=ak.flatten(file_arr - x_min, axis=None))
0304
0305 ys, edges = h.to_numpy()
0306 y0 = np.concatenate([ys, [ys[-1]]])
0307 legend_parts = [label]
0308 if ks_pvalue is not None:
0309 legend_parts.append(f"{100*ks_pvalue:.0f}%CL KS")
0310 if ad_pvalue is not None:
0311 legend_parts.append(f"{100*ad_pvalue:.0f}%CL AD")
0312 legend_label = "\n".join(legend_parts)
0313 source = ColumnDataSource(
0314 {
0315 "x": edges + x_min,
0316 "y1": y0 - np.sqrt(y0),
0317 "y2": y0 + np.sqrt(y0),
0318 }
0319 )
0320 step_r = fig.step(
0321 x="x",
0322 y={"expr": midpoint_expr},
0323 mode="after",
0324 source=source,
0325 legend_label=legend_label,
0326 line_color=color,
0327 line_width=line_width,
0328 line_dash=line_dash,
0329 )
0330 step_r.nonselection_glyph = step_r.glyph
0331 varea_r = fig.varea_step(
0332 x="x",
0333 y1="y1",
0334 y2="y2",
0335 step_mode="after",
0336 source=source,
0337 legend_label=legend_label,
0338 fill_color=color if hatch_pattern == " " else None,
0339 fill_alpha=0.25,
0340 hatch_color=color,
0341 hatch_alpha=0.5,
0342 hatch_pattern=hatch_pattern,
0343 )
0344 varea_r.nonselection_glyph = varea_r.glyph
0345 fig.legend.background_fill_alpha = 0.5
0346 fig.legend.location = "top_left"
0347
0348 y_max = max(y_max, np.max(y0 + np.sqrt(y0)))
0349 prev_file_arr = file_arr
0350
0351 if leaf_min_pvalue == 1.0:
0352 collection_matching_count[branch_name] = collection_matching_count.get(branch_name, 0) + 1
0353
0354 x_bounds = (x_min - 0.05 * x_range, x_min + 1.05 * x_range)
0355 y_bounds = (- 0.05 * y_max, 1.05 * y_max)
0356
0357 if np.all(np.isfinite(x_bounds)):
0358 try:
0359 fig.x_range = Range1d(
0360 *x_bounds,
0361 bounds=x_bounds)
0362 except ValueError as e:
0363 click.secho(str(e), fg="red", err=True)
0364 else:
0365 click.secho(f"overflow while calculating x bounds for \"{key}\"", fg="red", err=True)
0366 if np.all(np.isfinite(y_bounds)):
0367 try:
0368 fig.y_range = Range1d(
0369 *y_bounds,
0370 bounds=y_bounds)
0371 except ValueError as e:
0372 click.secho(str(e), fg="red", err=True)
0373 else:
0374 click.secho(f"overflow while calculating y bounds for \"{key}\"", fg="red", err=True)
0375
0376 def to_filename(branch_name):
0377 return branch_name.replace("#", "__pound__").replace("/", "__underscore__")
0378
0379 def option_key(item):
0380 collection_name, figs = item
0381 key = ""
0382 if collection_name in collection_with_diffs:
0383 if collection_with_diffs[collection_name] > 0.99:
0384 key += " 0.99"
0385 elif collection_with_diffs[collection_name] > 0.95:
0386 key += " 0.95"
0387 elif collection_with_diffs[collection_name] > 0.67:
0388 key += " 0.67"
0389 else:
0390 key += " 0.00"
0391 key += collection_name.lstrip("_")
0392 return key
0393
0394 options = [("", "")]
0395 for collection_name, figs in sorted(collection_figs.items(), key=option_key):
0396 marker = ""
0397 if collection_name in collection_with_diffs:
0398 if collection_with_diffs[collection_name] > 0.99:
0399 marker = " (*)"
0400 elif collection_with_diffs[collection_name] > 0.95:
0401 marker = " (**)"
0402 elif collection_with_diffs[collection_name] > 0.67:
0403 marker = " (***)"
0404 else:
0405 marker = " (****)"
0406 options.append((to_filename(collection_name), collection_name + marker))
0407
0408 from bokeh.models import CustomJS, Select, DataTable, TableColumn, HTMLTemplateFormatter, NumberFormatter, StringFormatter
0409 from bokeh.models.comparisons import CustomJSCompare
0410
0411
0412
0413 _ks_sorter = CustomJSCompare(code="""
0414 if (x === '' && y === '') return 0;
0415 if (x === '') return 1;
0416 if (y === '') return -1;
0417 const nx = parseFloat(x), ny = parseFloat(y);
0418 return nx < ny ? -1 : nx > ny ? 1 : 0;
0419 """)
0420
0421 _ad_sorter = CustomJSCompare(code="""
0422 if (x === '' && y === '') return 0;
0423 if (x === '') return 1;
0424 if (y === '') return -1;
0425 if (x === 'n/a' && y === 'n/a') return 0;
0426 if (x === 'n/a') return 1;
0427 if (y === 'n/a') return -1;
0428 const nx = parseFloat(x), ny = parseFloat(y);
0429 return nx < ny ? -1 : nx > ny ? 1 : 0;
0430 """)
0431
0432 def mk_summary_table():
0433 rows = []
0434 for collection_name, figs in sorted(
0435 collection_figs.items(),
0436 key=lambda item: item[0].lstrip("_"),
0437 ):
0438 if collection_name in collection_with_diffs:
0439 pvalue = collection_with_diffs[collection_name]
0440 if pvalue > 0.99:
0441 color = "#28a745"
0442 elif pvalue > 0.95:
0443 color = "#ffc107"
0444 elif pvalue > 0.67:
0445 color = "#fd7e14"
0446 else:
0447 color = "#dc3545"
0448 ks_str = (f"{collection_ks_pvalue[collection_name]:.3f}"
0449 if collection_name in collection_ks_pvalue else "")
0450 ad_str = (f"{collection_ad_pvalue[collection_name]:.3f}"
0451 if collection_name in collection_ad_pvalue else "n/a")
0452 else:
0453 color = "transparent"
0454 ks_str = ""
0455 ad_str = ""
0456 n_total = len(figs)
0457 n_match = collection_matching_count.get(collection_name, 0)
0458 n_diff = n_total - n_match
0459 rows.append((collection_name, color, ks_str, ad_str, n_match, n_diff, n_total))
0460
0461 source = ColumnDataSource({
0462 "collection": [r[0] for r in rows],
0463 "filename": [to_filename(r[0]) for r in rows],
0464 "color": [r[1] for r in rows],
0465 "ks_pvalue": [r[2] for r in rows],
0466 "ad_pvalue": [r[3] for r in rows],
0467 "nmatch": [r[4] for r in rows],
0468 "ndiff": [r[5] for r in rows],
0469 "nplots": [r[6] for r in rows],
0470 })
0471 square_style = (
0472 'display:inline-block;width:0.9em;height:0.9em;'
0473 'margin-right:6px;vertical-align:middle;'
0474 'border:1px solid #999;background-color:<%= color %>;'
0475 )
0476 link_fmt = HTMLTemplateFormatter(
0477 template=f'<span style="{square_style}"></span>'
0478 '<a href="#<%= filename %>"><%= value %></a>'
0479 )
0480 right_str = StringFormatter(text_align="right")
0481 right_num = NumberFormatter(text_align="right")
0482 columns = [
0483 TableColumn(field="collection", title="Collection", formatter=link_fmt, width=500),
0484 TableColumn(field="ks_pvalue", title="min KS p-value", formatter=right_str, width=120, sorter=_ks_sorter),
0485 TableColumn(field="ad_pvalue", title="min AD p-value", formatter=right_str, width=120, sorter=_ad_sorter),
0486 TableColumn(field="nmatch", title="# matching", formatter=right_num, width=80),
0487 TableColumn(field="ndiff", title="# differing", formatter=right_num, width=80),
0488 TableColumn(field="nplots", title="# plots", formatter=right_num, width=80),
0489 ]
0490 table = DataTable(
0491 source=source,
0492 columns=columns,
0493 width=800,
0494 sizing_mode="stretch_height",
0495 index_position=None,
0496 sortable=True,
0497 selectable=True,
0498 name="summary_table",
0499 )
0500 source.selected.js_on_change("indices", CustomJS(args={"source": source}, code="""
0501 const idx = cb_obj.indices;
0502 if (idx.length > 0) {
0503 // Meta/ctrl/shift-click is meant to open the row's link in a new
0504 // tab; don't also navigate this tab via the row-selection handler.
0505 if (window._lastClickModified) {
0506 source.selected.indices = [];
0507 return;
0508 }
0509 const filename = source.data["filename"][idx[0]];
0510 window.location.hash = "#" + filename;
0511 fetchAndReplaceBokehDocument(filename);
0512 }
0513 """))
0514 return table
0515
0516 def mk_dropdown(value=""):
0517 dropdown = Select(title="Select branch (**** < 67% CL, ..., * > 99% CL stat. equiv.):", value=value, options=options)
0518 dropdown.js_on_change("value", CustomJS(code="""
0519 console.log('dropdown: ' + this.value, this.toString())
0520 if (this.value != "") {
0521 window.location.hash = "#" + this.value;
0522 fetchAndReplaceBokehDocument(this.value);
0523 } else {
0524 // Empty option selected: navigate back to the index page.
0525 window.location.hash = "";
0526 }
0527 """))
0528 return dropdown
0529
0530 def mk_dropdown_minimal(value=""):
0531
0532
0533
0534 label = next((lbl for val, lbl in options if val == value), value)
0535 minimal_options = [("", "")] + ([(value, label)] if value else [])
0536 dropdown = Select(title="Select branch (**** < 67% CL, ..., * > 99% CL stat. equiv.):", value=value, options=minimal_options)
0537 dropdown.js_on_change("value", CustomJS(code="""
0538 console.log('dropdown: ' + this.value, this.toString())
0539 if (this.value != "") {
0540 window.location.hash = "#" + this.value;
0541 fetchAndReplaceBokehDocument(this.value);
0542 } else {
0543 // Empty option selected: navigate back to the index page.
0544 window.location.hash = "";
0545 }
0546 """))
0547 return dropdown
0548
0549 from bokeh.layouts import column
0550 from bokeh.embed import json_item
0551 import json
0552
0553 os.makedirs("capybara-reports", exist_ok=True)
0554
0555 for collection_name, figs in collection_figs.items():
0556 item = column(
0557 mk_dropdown_minimal(collection_name),
0558 gridplot(figs, ncols=3, width=400, height=300),
0559 )
0560
0561 with gzip.open(f"capybara-reports/{to_filename(collection_name)}.json.gz", "wt") as fp:
0562 json.dump(json_item(item), fp, separators=(',', ':'))
0563
0564 curdoc().js_on_event(DocumentReady, CustomJS(args={"all_options": options}, code="""
0565 window._bokehSelectOptions = all_options;
0566
0567 // Record whether a modifier key was held for the most recent click so the
0568 // table row-selection handler can tell a plain click (navigate this tab)
0569 // from a meta/ctrl/shift-click (open the link in a new tab only).
0570 document.addEventListener('mousedown', function(e) {
0571 window._lastClickModified = e.metaKey || e.ctrlKey || e.shiftKey;
0572 }, true);
0573
0574 function fetchAndReplaceBokehDocument(location) {
0575 fetch(location + '.json.gz')
0576 .then(async function(response) {
0577 if (!response.ok) {
0578 throw new Error('Network response was not ok');
0579 }
0580
0581 const ds = new DecompressionStream('gzip');
0582 const decompressedStream = response.body.pipeThrough(ds);
0583 const decompressedResponse = new Response(decompressedStream);
0584 const item = await decompressedResponse.json();
0585
0586 Bokeh.documents[0].replace_with_json(item.doc);
0587
0588 // Restore the full options list to the newly loaded Select widget.
0589 for (const [, model] of Bokeh.documents[0]._all_models) {
0590 if (model.options instanceof Array) {
0591 model.options = window._bokehSelectOptions;
0592 model.value = location;
0593 break;
0594 }
0595 }
0596 })
0597 .catch(function(error) {
0598 console.error('Fetch or decompression failed:', error);
0599 });
0600 }
0601
0602 // The hash encodes both the current page and the index table's sort state
0603 // as "#<filename>?sort=<field>:<asc|desc>;...". Keeping the sort in the
0604 // hash lets it survive the full-page reload that happens when navigating
0605 // back to the (regenerated) index page.
0606 function parseHash() {
0607 var raw = window.location.hash.replace(/^#/, "");
0608 var qi = raw.indexOf("?");
0609 if (qi < 0) {
0610 return { location: raw, query: "" };
0611 }
0612 return { location: raw.slice(0, qi), query: raw.slice(qi + 1) };
0613 }
0614
0615 function parseSort(query) {
0616 var s = new URLSearchParams(query).get("sort");
0617 if (!s) {
0618 return [];
0619 }
0620 return s.split(";").map(function(tok) {
0621 var parts = tok.split(":");
0622 return { field: parts[0], sortAsc: parts[1] !== "desc" };
0623 }).filter(function(c) { return c.field; });
0624 }
0625
0626 function encodeSort(cols) {
0627 if (!cols || cols.length === 0) {
0628 return "";
0629 }
0630 return cols.map(function(c) {
0631 return c.field + ":" + (c.sortAsc ? "asc" : "desc");
0632 }).join(";");
0633 }
0634
0635 // Set or clear a single parameter of the hash query, keeping the current
0636 // page and the other parameters intact. Uses replaceState so that
0637 // re-sorting/scrolling does not pile up history entries.
0638 function updateHashParam(key, value) {
0639 var h = parseHash();
0640 var params = new URLSearchParams(h.query);
0641 if (value === null || value === "") {
0642 params.delete(key);
0643 } else {
0644 params.set(key, value);
0645 }
0646 var q = params.toString();
0647 history.replaceState(null, "", "#" + h.location + (q ? "?" + q : ""));
0648 }
0649
0650 function getSummaryTableView() {
0651 try {
0652 return Bokeh.index.query_one(function(v) {
0653 return v.model && v.model.name === "summary_table" && v.grid != null;
0654 });
0655 } catch (e) {
0656 return null;
0657 }
0658 }
0659
0660 function applySortToTable(cols, attempt) {
0661 attempt = attempt || 0;
0662 var view = getSummaryTableView();
0663 if (view == null) {
0664 if (attempt < 40) {
0665 setTimeout(function() { applySortToTable(cols, attempt + 1); }, 50);
0666 }
0667 return;
0668 }
0669 var byField = {};
0670 view.grid.getColumns().forEach(function(gc) { byField[gc.field] = gc; });
0671 var slickSort = [], dataSort = [];
0672 cols.forEach(function(c) {
0673 var gc = byField[c.field];
0674 if (gc) {
0675 slickSort.push({ columnId: gc.id, sortAsc: c.sortAsc });
0676 dataSort.push({ sortCol: gc, sortAsc: c.sortAsc });
0677 }
0678 });
0679 view.grid.setSortColumns(slickSort);
0680 view.data.sort(dataSort);
0681 view.grid.invalidate();
0682 view.grid.render();
0683 view.model.update_sort_columns(dataSort);
0684 }
0685
0686 function setupSortPersistence(attempt) {
0687 attempt = attempt || 0;
0688 var view = getSummaryTableView();
0689 if (view == null) {
0690 if (attempt < 40) {
0691 setTimeout(function() { setupSortPersistence(attempt + 1); }, 50);
0692 }
0693 return;
0694 }
0695 var query = parseHash().query;
0696 var initial = parseSort(query);
0697 if (initial.length > 0) {
0698 applySortToTable(initial);
0699 }
0700 view.grid.onSort.subscribe(function(_event, args) {
0701 var cols = (args.sortCols || []).map(function(sc) {
0702 return { field: sc.sortCol.field, sortAsc: sc.sortAsc };
0703 });
0704 updateHashParam("sort", encodeSort(cols) || null);
0705 });
0706
0707 // The vertical scroll position lives in the SlickGrid viewport, which is
0708 // recreated (and reset) by the full-page reload on back navigation.
0709 // Persist it in the hash too so it survives the round trip.
0710 function viewport() {
0711 return view.shadow_el ? view.shadow_el.querySelector(".slick-viewport") : null;
0712 }
0713 var savedScroll = parseInt(new URLSearchParams(query).get("scroll") || "0", 10);
0714 if (savedScroll > 0) {
0715 setTimeout(function() {
0716 var vp = viewport();
0717 if (vp) { vp.scrollTop = savedScroll; }
0718 }, 150);
0719 }
0720 var scrollTimer = null;
0721 view.grid.onScroll.subscribe(function() {
0722 if (scrollTimer) { clearTimeout(scrollTimer); }
0723 scrollTimer = setTimeout(function() {
0724 // Guard against a late-firing timer writing to a collection page's
0725 // hash if the user navigated away right after scrolling.
0726 if (parseHash().location !== "") { return; }
0727 var vp = viewport();
0728 var y = vp ? Math.round(vp.scrollTop) : 0;
0729 updateHashParam("scroll", y > 0 ? String(y) : null);
0730 }, 150);
0731 });
0732 }
0733
0734 window.onhashchange = function() {
0735 var location = parseHash().location;
0736 if (location == "") {
0737 // No page in the hash: return to the index page. Since there is no
0738 // index.json.gz, just reload the page to get a fresh index.html (the
0739 // sort is restored from the hash by setupSortPersistence).
0740 if (typeof window.current_location !== 'undefined') {
0741 window.location.reload();
0742 }
0743 return;
0744 }
0745 if ((typeof current_location === 'undefined') || (current_location != location)) {
0746 fetchAndReplaceBokehDocument(location);
0747 window.current_location = location;
0748 }
0749 }
0750 window.onhashchange();
0751 setupSortPersistence();
0752 """))
0753 output_file(filename="capybara-reports/index.html", title="ePIC capybara report")
0754 save(column(
0755 mk_dropdown(),
0756 mk_summary_table(),
0757 sizing_mode="stretch_height",
0758 ))
0759
0760 if serve:
0761 os.chdir("capybara-reports/")
0762 from http.server import SimpleHTTPRequestHandler
0763 from socketserver import TCPServer
0764 with TCPServer(("127.0.0.1", 24535), SimpleHTTPRequestHandler) as httpd:
0765 print("Serving report at http://127.0.0.1:24535")
0766 try:
0767 httpd.serve_forever()
0768 except KeyboardInterrupt:
0769 pass