Back to home page

EIC code displayed by LXR

 
 

    


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 # Cap Anderson-Darling sample size to keep runtime bounded on
0023 # high-multiplicity collections.
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             # Not possible for PODIO, here for general ROOT file support
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             # not every file has the key
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             # diff, KS and Anderson-Darling k-sample tests
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                         # We can only apply the tests on non-empty arrays
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                         # Fast path: identical flattened contents
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                             # AD cost grows ~linearly with sample size and
0248                             # dominates the total runtime for high-multiplicity
0249                             # collections. Subsample above _AD_MAX_N per side:
0250                             # AD at N=1e4 already resolves p-values well below
0251                             # any threshold we colour on, so larger samples buy
0252                             # no useful sensitivity.
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                                 # anderson_ksamp fails if all samples are
0261                                 # identical or if there are too few distinct
0262                                 # values.
0263                                 ad_result = anderson_ksamp(
0264                                     [ad_a, ad_b],
0265                                     # n_resamples sets p-value resolution;
0266                                     # batch bounds peak memory (permutations
0267                                     # are otherwise materialized all at once,
0268                                     # which OOMs on large samples).
0269                                     # Seed the permutation RNG from the same
0270                                     # per-key stream used for subsampling so
0271                                     # the reported p-value is reproducible.
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             # Figure
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 # make legend more transparent
0346             fig.legend.location = "top_left" # left-align so glyph markers stay inside the canvas
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         # Set y range for histograms
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     # BokehJS creates the comparator as new Function("x", "y", ..., code),
0412     # so the cell values are available as `x` and `y` in the snippet.
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"  # green
0442                 elif pvalue > 0.95:
0443                     color = "#ffc107"  # yellow
0444                 elif pvalue > 0.67:
0445                     color = "#fd7e14"  # orange
0446                 else:
0447                     color = "#dc3545"  # red
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         # Embed only the currently selected option; the full list is stored once
0532         # in index.html's JavaScript and restored client-side after each load.
0533         # This avoids repeating a ~54 KB options list in every .json.gz file.
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