Back to home page

EIC code displayed by LXR

 
 

    


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 # 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         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             # not every file has the key
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             # diff, KS and Anderson-Darling k-sample tests
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                         # We can only apply the tests on non-empty arrays
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                         # Fast path: identical flattened contents
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                             # AD cost grows ~linearly with sample size and
0244                             # dominates the total runtime for high-multiplicity
0245                             # collections. Subsample above _AD_MAX_N per side:
0246                             # AD at N=1e4 already resolves p-values well below
0247                             # any threshold we colour on, so larger samples buy
0248                             # no useful sensitivity.
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                                 # anderson_ksamp fails if all samples are
0257                                 # identical or if there are too few distinct
0258                                 # values.
0259                                 ad_result = anderson_ksamp(
0260                                     [ad_a, ad_b],
0261                                     # n_resamples sets p-value resolution;
0262                                     # batch bounds peak memory (permutations
0263                                     # are otherwise materialized all at once,
0264                                     # which OOMs on large samples).
0265                                     # Seed the permutation RNG from the same
0266                                     # per-key stream used for subsampling so
0267                                     # the reported p-value is reproducible.
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             # Figure
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 # make legend more transparent
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         # Set y range for histograms
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     # BokehJS creates the comparator as new Function("x", "y", ..., code),
0407     # so the cell values are available as `x` and `y` in the snippet.
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"  # green
0437                 elif pvalue > 0.95:
0438                     color = "#ffc107"  # yellow
0439                 elif pvalue > 0.67:
0440                     color = "#fd7e14"  # orange
0441                 else:
0442                     color = "#dc3545"  # red
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         # Embed only the currently selected option; the full list is stored once
0520         # in index.html's JavaScript and restored client-side after each load.
0521         # This avoids repeating a ~54 KB options list in every .json.gz file.
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