• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

XENONnT / straxen / 15232639539

25 May 2025 12:58AM UTC coverage: 89.065% (-0.03%) from 89.094%
15232639539

push

github

web-flow
Set `unique` when `times` and `center_times` are not assigned (#1611)

0 of 1 new or added line in 1 file covered. (0.0%)

3 existing lines in 2 files now uncovered.

8829 of 9913 relevant lines covered (89.06%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.43
/straxen/analyses/bokeh_waveform_plot.py
1
import warnings
1✔
2

3
import bokeh
1✔
4
import bokeh.plotting as bklt
1✔
5
import numba
1✔
6
import numpy as np
1✔
7
import strax
1✔
8
import straxen
1✔
9
from straxen.analyses.holoviews_waveform_display import (
1✔
10
    _hvdisp_plot_records_2d,
11
    hook,
12
    plot_record_polygons,
13
    get_records_matrix_in_window,
14
)  # noqa
15

16
# Default legend, unknow, S1 and S2
17
LEGENDS = ("Unknown", "S1", "S2")
1✔
18
straxen._BOKEH_CONFIGURED_NOTEBOOK = False
1✔
19

20

21
@straxen.mini_analysis(
1✔
22
    requires=("event_basics", "peaks", "peak_basics", "peak_positions"), warn_beyond_sec=0.05
23
)
24
def event_display_interactive(
1✔
25
    events,
26
    peaks,
27
    to_pe,
28
    run_id,
29
    context,
30
    bottom_pmt_array=True,
31
    only_main_peaks=False,
32
    only_peak_detail_in_wf=False,
33
    plot_all_pmts=False,
34
    plot_record_matrix=False,
35
    plot_records_threshold=10,
36
    colors=("gray", "blue", "green"),
37
    yscale=("linear", "linear", "linear"),
38
    log=True,
39
):
40
    """Interactive event display for XENONnT. Plots detailed main/alt S1/S2, bottom and top PMT hit
41
    pattern as well as all other peaks in a given event.
42

43
    :param bottom_pmt_array: If true plots bottom PMT array hit-pattern.
44
    :param only_main_peaks: If true plots only main peaks into detail
45
        plots as well as PMT arrays.
46
    :param only_peak_detail_in_wf: Only plots main/alt S1/S2 into
47
        waveform. Only plot main peaks if only_main_peaks is true.
48
    :param plot_all_pmts: Bool if True, colors switched off PMTs instead
49
        of showing them in gray, useful for graphs shown in talks.
50
    :param plot_record_matrix: If true record matrix is plotted below.
51
        waveform.
52
    :param plot_records_threshold: Threshold at which zoom level to display
53
        record matrix as polygons. Larger values may lead to longer
54
        render times since more polygons are shown.
55
    :param colors: Colors to be used for peaks. Order is as peak types,
56
        0 = Unknown, 1 = S1, 2 = S2. Can be any colors accepted by bokeh.
57
    :param yscale: Defines scale for main/alt S1 == 0, main/alt S2 == 1,
58
        waveform plot == 2. Please note, that the log scale can lead to funny
59
        glyph renders for small values.
60
    :param log: If true color scale is used for hitpattern plots.
61

62
    example::
63

64
        from IPython.core.display import display, HTML
65
        display(HTML("<style>.container { width:80% !important; }</style>"))
66
        import bokeh.plotting as bklt
67
        fig = st.event_display_interactive(run_id, time_range=(event['time'], event['endtime']))
68
        bklt.show(fig)
69

70
    :raises:
71
        Raises an error if the user queries a time range which contains
72
        more than a single event.
73

74
    :return: bokeh.plotting.figure instance.
75

76
    """
77
    st = context
1✔
78

79
    if len(yscale) != 3:
1✔
80
        raise ValueError(f'"yscale" needs three entries, but you passed {len(yscale)}.')
81

82
    if not hasattr(st, "_BOKEH_CONFIGURED_NOTEBOOK"):
1✔
83
        st._BOKEH_CONFIGURED_NOTEBOOK = True
1✔
84
        # Configure show to show notebook:
85
        from bokeh.io import output_notebook
1✔
86

87
        output_notebook()
1✔
88

89
    if len(events) != 1:
1✔
90
        raise ValueError(
91
            "The time range you specified contains more or"
92
            " less than a single event. The event display "
93
            " only works with individual events for now."
94
        )
95

96
    if peaks.shape[0] == 0:
1✔
97
        raise ValueError("Found an event without peaks this should not had have happened.")
98

99
    # Select main/alt S1/S2s based on time and endtime in event:
100
    m_other_peaks = np.ones(len(peaks), dtype=bool)  # To select non-event peaks
1✔
101
    endtime = strax.endtime(peaks)
1✔
102

103
    signal = {}
1✔
104
    if only_main_peaks:
1✔
105
        s1_keys = ["s1"]
1✔
106
        s2_keys = ["s2"]
1✔
107
        labels = {"s1": "S1", "s2": "S2"}
1✔
108
    else:
109
        s1_keys = ["s1", "alt_s1"]
1✔
110
        s2_keys = ["s2", "alt_s2"]
1✔
111
        labels = {"s1": "MS1", "alt_s1": "AS1", "s2": "MS2", "alt_s2": "AS2"}
1✔
112

113
    for s_x in labels.keys():
1✔
114
        # Loop over Main/Alt Sx and get store S1/S2 Main/Alt in signals,
115
        # store information about other peaks as "m_other_peaks"
116
        m = (peaks["time"] == events[f"{s_x}_time"]) & (endtime == events[f"{s_x}_endtime"])
1✔
117
        signal[s_x] = peaks[m]
1✔
118
        m_other_peaks &= ~m
1✔
119

120
    # Detail plots for main/alt S1/S2:
121
    fig_s1, fig_s2 = plot_detail_plot_s1_s2(
1✔
122
        signal,
123
        s1_keys,
124
        s2_keys,
125
        labels,
126
        colors,
127
        yscale=yscale[:2],
128
    )
129

130
    # PMT arrays:
131
    if not only_main_peaks:
1✔
132
        # Plot all keys into both arrays:
133
        top_array_keys = s2_keys + s1_keys
1✔
134
        # Plot S1 first then S2 in the bottom array:
135
        bottom_array_keys = s1_keys + s2_keys
1✔
136
    else:
137
        top_array_keys = s2_keys
1✔
138
        bottom_array_keys = s1_keys
1✔
139

140
    fig_top, fig_bottom = plot_pmt_arrays_and_positions(
1✔
141
        top_array_keys,
142
        bottom_array_keys,
143
        signal,
144
        to_pe,
145
        labels,
146
        plot_all_pmts,
147
        log=log,
148
    )
149

150
    m_other_s2 = m_other_peaks & (peaks["type"] == 2)
1✔
151
    if np.any(m_other_s2) and not only_main_peaks:
1✔
152
        # Now we have to add the positions of all the other S2 to the top pmt array
153
        # if not only main peaks.
154
        fig_top, plots = plot_posS2s(
×
155
            peaks[m_other_s2], label="OS2s", fig=fig_top, s2_type_style_id=2
156
        )
157
        for plot in plots:
×
158
            plot.visible = False
×
159

160
    # Main waveform plot:
161
    if only_peak_detail_in_wf:
1✔
162
        # If specified by the user only plot main/alt S1/S2
163
        peaks = peaks[~m_other_peaks]
×
164

165
    waveform = plot_event(peaks, signal, labels, events[0], colors, yscale=yscale[-1])
1✔
166

167
    # Create tile:
168
    title = _make_event_title(events[0], run_id)
1✔
169

170
    # Put everything together:
171
    if bottom_pmt_array:
1✔
172
        upper_row = [fig_s1, fig_s2, fig_top, fig_bottom]
1✔
173
    else:
174
        upper_row = [fig_s1, fig_s2, fig_top]
×
175

176
    upper_row = bokeh.layouts.Row(
1✔
177
        children=upper_row,
178
        sizing_mode="scale_width",
179
    )
180

181
    plots = bokeh.layouts.gridplot(
1✔
182
        children=[upper_row, waveform],
183
        sizing_mode="scale_width",
184
        ncols=1,
185
        merge_tools=True,
186
        toolbar_location="above",
187
    )
188
    event_display = bokeh.layouts.Column(
1✔
189
        children=[title, plots],
190
        sizing_mode="scale_width",
191
        max_width=1600,
192
    )
193

194
    # Add record matrix if asked:
195
    if plot_record_matrix:
1✔
196
        if st.is_stored(run_id, "records"):
1✔
197
            # Check if records can be found and load:
198
            r = st.get_array(
1✔
199
                run_id, "records", time_range=(events[0]["time"], events[0]["endtime"])
200
            )
201
        elif st.is_stored(run_id, "raw_records"):
×
202
            warnings.warn(
×
203
                f"Cannot find records for {run_id}, making them from raw_records instead."
204
            )
205
            p = st.get_single_plugin(run_id, "records")
×
206
            r = st.get_array(
×
207
                run_id, "raw_records", time_range=(events[0]["time"], events[0]["endtime"])
208
            )
209
            r = p.compute(r, events[0]["time"], events[0]["endtime"])["records"]
×
210
        else:
211
            warnings.warn(
×
212
                f"Can neither find records nor raw_records for run {run_id}, proceed without record"
213
                " matrix."
214
            )
215
            plot_record_matrix = False
×
216

217
    if plot_record_matrix:
1✔
218
        straxen._BOKEH_X_RANGE = None
1✔
219
        # First get hook to for x_range:
220
        x_range_hook = lambda plot, element: hook(plot, x_range=straxen._BOKEH_X_RANGE, debug=False)
1✔
221

222
        # Create datashader plot:
223
        wf, record_points, time_stream = _hvdisp_plot_records_2d(
1✔
224
            records=r,
225
            to_pe=to_pe,
226
            t_reference=peaks[0]["time"],
227
            event_range=(waveform.x_range.start, waveform.x_range.end),
228
            config=st.config,
229
            hooks=[x_range_hook],
230
            tools=[],
231
        )
232
        # Create record polygons:
233
        polys = plot_record_polygons(record_points)
1✔
234
        records_in_window = polys.apply(
1✔
235
            get_records_matrix_in_window, streams=[time_stream], time_slice=plot_records_threshold
236
        )
237

238
        # Render plot to initialize x_range:
239
        import holoviews as hv
1✔
240
        import panel
1✔
241

242
        _ = hv.render(wf)
1✔
243
        # Set x-range of event plot:
244
        bokeh_set_x_range(waveform, straxen._BOKEH_X_RANGE, debug=False)
1✔
245
        event_display = panel.Column(
1✔
246
            event_display,
247
            wf * records_in_window,
248
            sizing_mode="scale_width",
249
        )
250

251
    return event_display
1✔
252

253

254
def plot_detail_plot_s1_s2(
1✔
255
    signal,
256
    s1_keys,
257
    s2_keys,
258
    labels,
259
    colors,
260
    title=["Main/Alt S1", "Main/Alt S2"],
261
    yscale=("linear", "linear"),
262
):
263
    """Function to plot the main/alt S1/S2 peak details.
264

265
    :param signal: Dictionary containing the peak information.
266
    :param s1_keys: S1 keys to be plotted e.g. with and without alt S1
267
    :param s2_keys: Same but for S2
268
    :param labels: Labels to be used for Peaks
269
    :param colors: Colors to be used
270
    :param yscale: Tuple with axis scale type.
271
    :return: S1 and S2 bokeh figure.
272

273
    """
274
    # First we create figure then we loop over figures and plots and
275
    # add drawings:
276
    fig_s1 = straxen.bokeh_utils.default_fig(
1✔
277
        title=title[0],
278
        y_axis_type=yscale[0],
279
    )
280
    fig_s2 = straxen.bokeh_utils.default_fig(
1✔
281
        title=title[1],
282
        y_axis_type=yscale[1],
283
    )
284

285
    for fig, peak_types in zip([fig_s1, fig_s2], (s1_keys, s2_keys)):
1✔
286
        # Loop over fig and corresponding peak keys
287
        for peak_type in peak_types:
1✔
288
            if "s2" in peak_type:
1✔
289
                # If S2 use µs as units
290
                time_scalar = 1000  # ns
1✔
291
                unit = "µs"
1✔
292
            else:
293
                time_scalar = 1  # ns
1✔
294
                unit = "ns"
1✔
295
            if signal[peak_type].shape[0]:
1✔
296
                # If signal exists, plot:
297
                fig, plot = plot_peak_detail(
1✔
298
                    signal[peak_type],
299
                    time_scalar=time_scalar,
300
                    label=labels[peak_type],
301
                    unit=unit,
302
                    fig=fig,
303
                    colors=colors,
304
                )
305
                if "alt" in peak_type:
1✔
306
                    # Not main S1/S2, so make peak invisible
307
                    plot.visible = False
×
308
    return fig_s1, fig_s2
1✔
309

310

311
def plot_pmt_arrays_and_positions(
1✔
312
    top_array_keys, bottom_array_keys, signal, to_pe, labels, plot_all_pmts, log=True
313
):
314
    """Function which plots the Top and Bottom PMT array.
315

316
    :return: fig_top, fig_bottom
317

318
    """
319
    # Same logic as for detailed Peaks, first make figures
320
    # then loop over figures and data and populate figures with plots
321
    fig_top = straxen.bokeh_utils.default_fig(title="Top array")
1✔
322
    fig_bottom = straxen.bokeh_utils.default_fig(title="Bottom array")
1✔
323

324
    for pmt_array_type, fig, peak_types in zip(
1✔
325
        ["top", "bottom"], [fig_top, fig_bottom], [top_array_keys, bottom_array_keys]
326
    ):
327
        for ind, k in enumerate(peak_types):
1✔
328
            # Loop over peaks enumerate them since we plot all Peaks
329
            # Main/ALt S1/S2 into the PMT array, but only the first one
330
            # Should be visible.
331
            if not signal[k].shape[0]:
1✔
332
                # alt S1/S2 does not exist so go to next.
333
                continue
1✔
334

335
            fig, plot, _ = plot_pmt_array(
1✔
336
                signal[k][0],
337
                pmt_array_type,
338
                to_pe,
339
                plot_all_pmts=plot_all_pmts,
340
                label=labels[k],
341
                fig=fig,
342
                log=log,
343
            )
344
            if ind:
1✔
345
                # Not main S1 or S2
346
                plot.visible = False
1✔
347

348
            if pmt_array_type == "top" and "s2" in k:
1✔
349
                # In case of the top PMT array we also have to plot the S2 positions:
350
                fig, plots = plot_posS2s(
1✔
351
                    signal[k][0], label=labels[k], fig=fig, s2_type_style_id=ind
352
                )
353
                if ind:
1✔
354
                    # Not main S2
355
                    for plot in plots:
×
356
                        plot.visible = False
×
357

358
    return fig_top, fig_bottom
1✔
359

360

361
def plot_event(peaks, signal, labels, event, colors, yscale="linear"):
1✔
362
    """Wrapper for plot peaks to highlight main/alt. S1/S2.
363

364
    :param peaks: Peaks in event
365
    :param signal: Dictionary containing main/alt. S1/S2
366
    :param labels: dict with labels to be used
367
    :param event: Event to set correctly x-ranges.
368
    :param colors: Colors to be used for unknown, s1 and s2 signals.
369
    :param yscale: string of yscale type.
370
    :return: bokeh.plotting.figure instance
371

372
    """
373
    waveform = plot_peaks(peaks, time_scalar=1000, colors=colors, yscale=yscale)
1✔
374
    # Highlight main and alternate S1/S2:
375
    start = peaks[0]["time"]
1✔
376
    end = strax.endtime(peaks)[-1]
1✔
377
    # Workaround did not manage to scale via pixels...
378
    ymax = np.max((peaks["data"].T / peaks["dt"]).T)
1✔
379
    ymax -= 0.1 * ymax
1✔
380
    for s, p in signal.items():
1✔
381
        if p.shape[0]:
1✔
382
            pos = (p[0]["center_time"] - start) / 1000
1✔
383
            main = bokeh.models.Span(
1✔
384
                location=pos,
385
                dimension="height",
386
                line_alpha=0.6,
387
            )
388
            vline_label = bokeh.models.Label(
1✔
389
                x=pos,
390
                y=ymax,
391
                angle=np.pi / 2,
392
                text=labels[s],
393
            )
394
            if "alt" in s:
1✔
395
                main.line_dash = "dotted"
×
396
            else:
397
                main.line_dash = "dashed"
1✔
398
            waveform.add_layout(main)
1✔
399
            waveform.add_layout(vline_label)
1✔
400

401
    # Get some meaningful x-range limit to 10% left and right extending
402
    # beyond first last peak, clip at event boundary.
403
    length = (end - start) / 10**3
1✔
404

405
    waveform.x_range.start = max(-0.1 * length, (event["time"] - start) / 10**3)
1✔
406
    waveform.x_range.end = min(1.1 * length, (event["endtime"] - start) / 10**3)
1✔
407
    return waveform
1✔
408

409

410
@straxen.mini_analysis(
1✔
411
    requires=("peaks", "peak_basics", "peak_positions"),
412
    default_time_selection="touching",
413
    warn_beyond_sec=10,
414
)
415
def peaks_display_interactive(
1✔
416
    peaks,
417
    to_pe,
418
    run_id,
419
    context,
420
    times=[],
421
    center_times=[],
422
    bottom_pmt_array=True,
423
    plot_all_pmts=False,
424
    colors=("gray", "blue", "green"),
425
    yscale=("linear", "linear", "linear"),
426
    log=True,
427
    _provide_peaks=False,
428
):
429
    """Interactive events display for XENONnT. Plots detailed waveform, bottom and top PMT hit
430
    pattern for selected center time.
431

432
    :param bottom_pmt_array: If true plots bottom PMT array hit-pattern.
433
    :param colors: Colors to be used for peaks. Order is as peak types, 0 = Unknown, 1 = S1, 2 = S2.
434
        Can be any colors accepted by bokeh.
435
    :param yscale: Defines scale for main/alt S1 == 0, main/alt S2 == 1, waveform plot == 2. Please
436
        note, that the log scale can lead to funny glyph renders for small values.
437
    :param log: If true color scale is used for hitpattern plots.
438
    :return: bokeh.plotting.figure instance.
439

440
    """
441
    st = context
1✔
442

443
    if len(yscale) != 3:
1✔
444
        raise ValueError(f'"yscale" needs three entries, but you passed {len(yscale)}.')
445

446
    if not hasattr(st, "_BOKEH_CONFIGURED_NOTEBOOK"):
1✔
447
        st._BOKEH_CONFIGURED_NOTEBOOK = True
×
448
        # Configure show to show notebook:
449
        from bokeh.io import output_notebook
×
450

451
        output_notebook()
×
452

453
    if len(times) and len(center_times):
1✔
454
        raise ValueError("Please specify either times or center_times, not both.")
455
    if len(times):
1✔
456
        unique = np.unique(times)
×
457
        field = "time"
×
458
    elif len(center_times):
1✔
459
        unique = np.unique(center_times)
1✔
460
        field = "center_time"
1✔
461
    else:
NEW
462
        unique = []
×
UNCOV
463
        warnings.warn("No times or center_times specified, will not plot any peak in detail.")
×
464

465
    signal = {}
1✔
466
    s1_keys = []
1✔
467
    s2_keys = []
1✔
468
    labels = {}
1✔
469
    found_s1 = False
1✔
470
    found_s2 = False
1✔
471
    for ind, t in enumerate(unique):
1✔
472
        _p = peaks[peaks[field] == t]
1✔
473
        if not _p.shape[0]:
1✔
474
            raise ValueError(f"Could not find peak at center time {t}.")
475
        if len(_p) > 1:
1✔
476
            warnings.warn(f"Found multiple peaks at {field} {t}, using the first one.")
×
477
        p = _p[0]
1✔
478
        if np.isin(p["type"], [0, 1, 3]):
1✔
479
            key = f"s{p['type']}_{ind}"
1✔
480
            if found_s1:
1✔
481
                key = f"alt_{key}"
×
482
            found_s1 = True
1✔
483
            s1_keys.append(key)
1✔
484
        else:
485
            key = f"s{p['type']}_{ind}"
1✔
486
            if found_s2:
1✔
487
                key = f"alt_{key}"
×
488
            assert "s2" in key, "Only S2 peaks are allowed here."
1✔
489
            found_s2 = True
1✔
490
            s2_keys.append(key)
1✔
491
        signal[key] = _p
1✔
492
        labels[key] = f"{ind}"
1✔
493

494
    # Detail plots for selected peaks
495
    fig_s1, fig_s2 = plot_detail_plot_s1_s2(
1✔
496
        signal,
497
        s1_keys,
498
        s2_keys,
499
        labels,
500
        colors,
501
        title=["S0, S1 and others", "S2 and others"],
502
        yscale=yscale[:2],
503
    )
504

505
    # PMT arrays:
506
    # Plot all keys into both arrays:
507
    top_array_keys = s2_keys + s1_keys
1✔
508
    # Plot S1 first then S2 in the bottom array:
509
    bottom_array_keys = s1_keys + s2_keys
1✔
510

511
    fig_top, fig_bottom = plot_pmt_arrays_and_positions(
1✔
512
        top_array_keys,
513
        bottom_array_keys,
514
        signal,
515
        to_pe,
516
        labels,
517
        plot_all_pmts,
518
        log=log,
519
    )
520

521
    event = np.zeros(1, dtype=strax.time_fields)
1✔
522
    event["time"] = peaks[0]["time"]
1✔
523
    event["endtime"] = strax.endtime(peaks)[-1]
1✔
524
    waveform = plot_event(peaks, signal, labels, event[0], colors, yscale[-1])
1✔
525

526
    # Create tile:
527
    title = _make_peaks_title(peaks, run_id)
1✔
528

529
    # Put everything together:
530
    if bottom_pmt_array:
1✔
531
        upper_row = [fig_s1, fig_s2, fig_top, fig_bottom]
1✔
532
    else:
533
        upper_row = [fig_s1, fig_s2, fig_top]
×
534

535
    upper_row = bokeh.layouts.Row(
1✔
536
        children=upper_row,
537
        sizing_mode="scale_width",
538
    )
539

540
    plots = bokeh.layouts.gridplot(
1✔
541
        children=[upper_row, waveform],
542
        sizing_mode="scale_width",
543
        ncols=1,
544
        merge_tools=True,
545
        toolbar_location="above",
546
    )
547
    event_display = bokeh.layouts.Column(
1✔
548
        children=[title, plots],
549
        sizing_mode="scale_width",
550
        max_width=1600,
551
    )
552

553
    if _provide_peaks:
1✔
554
        return event_display, peaks
×
555

556
    return event_display
1✔
557

558

559
def plot_peak_detail(
1✔
560
    peak,
561
    time_scalar=1,
562
    label="",
563
    unit="ns",
564
    colors=("gray", "blue", "green"),
565
    fig=None,
566
):
567
    """Function which makes a detailed plot for the given peak. As in the main/alt S1/S2 plots of
568
    the event display.
569

570
    :param peak: Peak to be plotted.
571
    :param time_scalar: Factor to rescale the time from ns to other scale. E.g. =1000 scales to µs.
572
    :param label: Label to be used in the plot legend.
573
    :param unit: Time unit of the plotted peak.
574
    :param colors: Colors to be used for unknown, s1 and s2 peaks.
575
    :param fig: Instance of bokeh.plotting.figure if None one will be created via
576
        straxen.bokeh.utils.default_figure().
577
    :return: Instance of bokeh.plotting.figure
578

579
    """
580
    if not peak.shape:
1✔
581
        peak = np.array([peak])
×
582

583
    if peak.shape[0] != 1:
1✔
584
        raise ValueError(
585
            "Cannot plot the peak details for more than one "
586
            "peak. Please make sure peaks has the shape (1,)!"
587
        )
588

589
    p_type = peak[0]["type"]
1✔
590

591
    if not fig:
1✔
592
        fig = straxen.bokeh_utils.default_fig(title=f"Main/Alt S{p_type}")
×
593

594
    source = straxen.bokeh_utils.get_peaks_source(
1✔
595
        peak,
596
        relative_start=peak[0]["time"],
597
        time_scaler=time_scalar,
598
        keep_amplitude_per_sample=False,
599
    )
600

601
    _i = p_type if p_type < len(colors) else 0
1✔
602
    patches = fig.patches(
1✔
603
        source=source,
604
        legend_label=label,
605
        fill_color=colors[_i],
606
        fill_alpha=0.2,
607
        line_color=colors[_i],
608
        line_width=0.5,
609
        name=label,
610
    )
611
    tt = straxen.bokeh_utils.peak_tool_tip(p_type)
1✔
612
    tt = [v for k, v in tt.items() if k not in ["time_static", "center_time", "endtime"]]
1✔
613
    fig.add_tools(bokeh.models.HoverTool(name=label, tooltips=tt, renderers=[patches]))
1✔
614
    fig.xaxis.axis_label = f"Time [{unit}]"
1✔
615
    fig.xaxis.axis_label_text_font_size = "12pt"
1✔
616
    fig.yaxis.axis_label = "Amplitude [pe/ns]"
1✔
617
    fig.yaxis.axis_label_text_font_size = "12pt"
1✔
618

619
    fig.legend.location = "top_right"
1✔
620
    fig.legend.click_policy = "hide"
1✔
621

622
    if label:
1✔
623
        fig.legend.visible = True
1✔
624
    else:
625
        fig.legend.visible = False
×
626

627
    return fig, patches
1✔
628

629

630
def plot_peaks(peaks, time_scalar=1, fig=None, colors=("gray", "blue", "green"), yscale="linear"):
1✔
631
    """Function which plots a list/array of peaks relative to the first one.
632

633
    :param peaks: Peaks to be plotted.
634
    :param time_scalar: Factor to rescale the time from ns to other scale. E.g. =1000 scales to µs.
635
    :param colors: Colors to be used for unknown, s1 and s2 signals
636
    :param yscale: yscale type can be "linear" or "log"
637
    :param fig: Instance of bokeh.plotting.figure if None one will be created via
638
        straxen.bokeh.utils.default_figure().
639
    :return: bokeh.plotting.figure instance.
640

641
    """
642
    if not fig:
1✔
643
        fig = straxen.bokeh_utils.default_fig(width=1600, height=400, y_axis_type=yscale)
1✔
644

645
    for i in np.unique(peaks["type"]):
1✔
646
        _ind = np.where(peaks["type"] == i)[0]
1✔
647
        if not len(_ind):
1✔
648
            continue
×
649

650
        source = straxen.bokeh_utils.get_peaks_source(
1✔
651
            peaks[_ind],
652
            relative_start=peaks[0]["time"],
653
            time_scaler=time_scalar,
654
            keep_amplitude_per_sample=False,
655
        )
656

657
        _i = i if i < len(colors) else 0
1✔
658
        p = fig.patches(
1✔
659
            source=source,
660
            fill_color=colors[_i],
661
            fill_alpha=0.2,
662
            line_color=colors[_i],
663
            line_width=0.5,
664
            legend_label=LEGENDS[_i],
665
            name=LEGENDS[_i],
666
        )
667

668
        tt = straxen.bokeh_utils.peak_tool_tip(i)
1✔
669
        tt = [v for k, v in tt.items() if k != "time_dynamic"]
1✔
670
        fig.add_tools(bokeh.models.HoverTool(name=LEGENDS[_i], tooltips=tt, renderers=[p]))
1✔
671
        fig.add_tools(bokeh.models.WheelZoomTool(dimensions="width", name="wheel"))
1✔
672
        fig.toolbar.active_scroll = [t for t in fig.tools if t.name == "wheel"][0]
1✔
673

674
    fig.xaxis.axis_label = "Time [µs]"
1✔
675
    fig.xaxis.axis_label_text_font_size = "12pt"
1✔
676
    fig.yaxis.axis_label = "Amplitude [pe/ns]"
1✔
677
    fig.yaxis.axis_label_text_font_size = "12pt"
1✔
678

679
    fig.legend.location = "top_left"
1✔
680
    fig.legend.click_policy = "hide"
1✔
681
    return fig
1✔
682

683

684
def plot_pmt_array(
1✔
685
    peak,
686
    array_type,
687
    to_pe,
688
    plot_all_pmts=False,
689
    log=False,
690
    fig=None,
691
    label="",
692
):
693
    """Plots top or bottom PMT array for given peak.
694

695
    :param peak: Peak for which the hit pattern should be plotted.
696
    :param array_type: String which specifies if "top" or "bottom" PMT array should be plotted
697
    :param to_pe: PMT gains.
698
    :param log: If true use a log-scale for the color scale.
699
    :param plot_all_pmts: If True colors all PMTs instead of showing swtiched off PMTs as gray dots.
700
    :param fig: Instance of bokeh.plotting.figure if None one will be created via
701
        straxen.bokeh.utils.default_figure().
702
    :param label: Label of the peak which should be used for the plot legend
703
    :return: Tuple containing a bokeh figure, glyph and transform instance.
704

705
    """
706
    if peak.shape:
1✔
707
        raise ValueError("Can plot PMT array only for a single peak at a time.")
708

709
    tool_tip = [
1✔
710
        ("plot", "$name"),
711
        ("channel", "@pmt"),
712
        ("x [cm]", "$x"),
713
        ("y [cm]", "$y"),
714
        ("area [pe]", "@area"),
715
    ]
716

717
    array = ("top", "bottom")
1✔
718
    if array_type not in array:
1✔
719
        raise ValueError('"array_type" must be either top or bottom.')
720

721
    if not fig:
1✔
722
        fig = straxen.bokeh_utils.default_fig(title=f"{array_type} array")
×
723

724
    # Creating TPC axis and title
725
    fig = _plot_tpc(fig)
1✔
726

727
    # Plotting PMTs:
728
    pmts = straxen.pmt_positions()
1✔
729
    if plot_all_pmts:
1✔
730
        mask_pmts = np.zeros(len(pmts), dtype=bool)
×
731
    else:
732
        mask_pmts = to_pe == 0
1✔
733
    pmts_on = pmts[~mask_pmts]
1✔
734
    pmts_on = pmts_on[pmts_on["array"] == array_type]
1✔
735

736
    if np.any(mask_pmts):
1✔
737
        pmts_off = pmts[mask_pmts]
1✔
738
        pmts_off = pmts_off[pmts_off["array"] == array_type]
1✔
739
        fig = _plot_off_pmts(pmts_off, fig)
1✔
740

741
    area_per_channel = peak["area_per_channel"][pmts_on["i"]]
1✔
742

743
    if log:
1✔
744
        area_plot = np.log10(area_per_channel)
1✔
745
        # Manually set infs to zero since cmap cannot handle it.
746
        area_plot = np.where(area_plot == -np.inf, 0, area_plot)
1✔
747
    else:
748
        area_plot = area_per_channel
×
749

750
    mapper = bokeh.transform.linear_cmap(
1✔
751
        field_name="area_plot", palette="Viridis256", low=min(area_plot), high=max(area_plot)
752
    )
753

754
    source_on = bklt.ColumnDataSource(
1✔
755
        data={
756
            "x": pmts_on["x"],
757
            "y": pmts_on["y"],
758
            "area": area_per_channel,
759
            "area_plot": area_plot,
760
            "pmt": pmts_on["i"],
761
        }
762
    )
763

764
    p = fig.scatter(
1✔
765
        source=source_on,
766
        radius=straxen.tpc_pmt_radius,
767
        fill_color=mapper,
768
        fill_alpha=1,
769
        line_color="black",
770
        legend_label=label,
771
        name=label + " PMT array",
772
    )
773
    fig.add_tools(
1✔
774
        bokeh.models.HoverTool(name=label + " PMT array", tooltips=tool_tip, renderers=[p])
775
    )
776
    fig.legend.location = "top_left"
1✔
777
    fig.legend.click_policy = "hide"
1✔
778
    fig.legend.orientation = "horizontal"
1✔
779
    fig.legend.padding = 0
1✔
780
    fig.toolbar_location = None
1✔
781
    return fig, p, mapper
1✔
782

783

784
def _plot_tpc(fig=None):
1✔
785
    """Plots ring at TPC radius and sets xy limits + labels."""
786
    if not fig:
1✔
787
        fig = straxen.bokeh_utils.default_fig()
×
788

789
    fig.circle(
1✔
790
        x=0,
791
        y=0,
792
        radius=straxen.tpc_r,
793
        fill_color="white",
794
        line_color="black",
795
        line_width=3,
796
        fill_alpha=0,
797
    )
798
    fig.xaxis.axis_label = "x [cm]"
1✔
799
    fig.xaxis.axis_label_text_font_size = "12pt"
1✔
800
    fig.yaxis.axis_label = "y [cm]"
1✔
801
    fig.yaxis.axis_label_text_font_size = "12pt"
1✔
802
    fig.x_range.start = -80
1✔
803
    fig.x_range.end = 80
1✔
804
    fig.y_range.start = -80
1✔
805
    fig.y_range.end = 80
1✔
806

807
    return fig
1✔
808

809

810
def _plot_off_pmts(pmts, fig=None):
1✔
811
    """Plots PMTs which are switched off."""
812
    if not fig:
1✔
813
        fig = straxen.bokeh_utils.default_fig()
×
814
    fig.circle(
1✔
815
        x=pmts["x"],
816
        y=pmts["y"],
817
        fill_color="gray",
818
        line_color="black",
819
        radius=straxen.tpc_pmt_radius,
820
    )
821
    return fig
1✔
822

823

824
def plot_posS2s(peaks, label="", fig=None, s2_type_style_id=0):
1✔
825
    """Plots xy-positions of specified peaks.
826

827
    :param peaks: Peaks for which the position should be plotted.
828
    :param label: Legend label and plot name (name serves as idenitfier).
829
    :param fig: bokeh.plotting.figure instance the plot should be plotted into. If None creates new
830
        instance.
831
    :param s2_type_style_id: 0 plots main S2 style, 1 for alt S2 and 2 for other S2s (e.g. single
832
        electrons).
833

834
    """
835
    if not peaks.shape:
1✔
836
        peaks = np.array([peaks])
1✔
837

838
    if not fig:
1✔
839
        fig = straxen.bokeh_utils.default_fig()
×
840

841
    source = straxen.bokeh_utils.get_peaks_source(peaks)
1✔
842

843
    if s2_type_style_id == 0:
1✔
844
        color = "red"
1✔
845
        p = fig.cross(
1✔
846
            source=source, name=label, legend_label=label, color=color, line_width=2, size=12
847
        )
848
    elif s2_type_style_id == 1:
×
849
        color = "orange"
×
850
        p = fig.cross(
×
851
            source=source,
852
            name=label,
853
            legend_label=label,
854
            color=color,
855
            angle=45 / 360 * 2 * np.pi,
856
            line_width=2,
857
            size=12,
858
        )
859
    else:
860
        color = "red"
×
861
        p = fig.diamond_cross(source=source, name=label, legend_label=label, color=color, size=8)
×
862

863
    tt = straxen.bokeh_utils.peak_tool_tip(2)
1✔
864
    tt = [v for k, v in tt.items() if k not in ["time_dynamic", "amplitude"]]
1✔
865
    fig.add_tools(
1✔
866
        bokeh.models.HoverTool(
867
            name=label,
868
            tooltips=[("x [cm]", "@x"), ("y [cm]", "@y")] + tt,
869
            renderers=[p],
870
        )
871
    )
872

873
    if "position_contour_cnf" in peaks.dtype.names:
1✔
874
        # Plotting the contour of the S2
875
        c = fig.multi_line(
1✔
876
            peaks["position_contour_cnf"][:, :, 0].tolist(),
877
            peaks["position_contour_cnf"][:, :, 1].tolist(),
878
            name=label,
879
            legend_label=label,
880
            color=color,
881
            line_width=1.0,
882
        )
883
        return fig, [p, c]
1✔
884
    return fig, [p]
×
885

886

887
def _make_event_title(event, run_id, width=1600):
1✔
888
    """Function which makes the title of the plot for the specified event.
889

890
    Note:
891
        To center the title I use a transparent box.
892

893
    :param event: Event which we are plotting
894
    :param run_id: run_id
895

896
    :return: Title as bokeh.models.Div instance
897

898
    """
899
    start = event["time"]
1✔
900
    date = np.datetime_as_string(start.astype("<M8[ns]"), unit="s")
1✔
901
    start_ns = start - (start // straxen.units.s) * straxen.units.s
1✔
902
    end = strax.endtime(event)
1✔
903
    end_ns = end - start + start_ns
1✔
904
    event_number = event["event_number"]
1✔
905
    text = (
1✔
906
        f"<h2>Event {event_number} from run {run_id}<br>"
907
        f"Recorded at {date[:10]} {date[10:]} UTC, {start_ns} ns - {end_ns} ns<br>"
908
        f"({start} - {end})</h2>"
909
    )
910

911
    title = bokeh.models.Div(
1✔
912
        text=text,
913
        styles={
914
            "text-align": "left",
915
        },
916
        sizing_mode="scale_width",
917
        width=width,
918
        # orientation='vertical',
919
        width_policy="fit",
920
        margin=(0, 0, -30, 50),
921
    )
922
    return title
1✔
923

924

925
def _make_peaks_title(peaks, run_id, width=1600):
1✔
926
    """Function which makes the title of the plot for the specified peaks.
927

928
    Note:
929
        To center the title I use a transparent box.
930

931
    :param peaks: Peaks which we are plotting
932
    :param run_id: run_id
933

934
    :return: Title as bokeh.models.Div instance
935

936
    """
937
    start = peaks["time"].min()
1✔
938
    date = np.datetime_as_string(start.astype("<M8[ns]"), unit="s")
1✔
939
    start_ns = start - (start // straxen.units.s) * straxen.units.s
1✔
940
    end = strax.endtime(peaks).max()
1✔
941
    end_ns = end - start + start_ns
1✔
942
    text = (
1✔
943
        f"<h2>Peaks from run {run_id}<br>"
944
        f"Recorded at {date[:10]} {date[10:]} UTC, {start_ns} ns - {end_ns} ns<br>"
945
        f"({start} - {end})</h2>"
946
    )
947

948
    title = bokeh.models.Div(
1✔
949
        text=text,
950
        styles={
951
            "text-align": "left",
952
        },
953
        sizing_mode="scale_width",
954
        width=width,
955
        # orientation='vertical',
956
        width_policy="fit",
957
        margin=(0, 0, -30, 50),
958
    )
959
    return title
1✔
960

961

962
def bokeh_set_x_range(plot, x_range, debug=False):
1✔
963
    """Function which adjust java script call back for x_range of a bokeh plot. Required to link
964
    bokeh and holoviews x_range.
965

966
    Note:
967
        This is somewhat voodoo + some black magic,
968
        but it works....
969

970
    """
971
    from bokeh.models import CustomJS
1✔
972

973
    code = """\
1✔
974
    const start = cb_obj.start;
975
    const end = cb_obj.end;
976
    // Need to update the attributes at the same time.
977
    x_range.setv({start, end});
978
    """
979
    for attr in ["start", "end"]:
1✔
980
        if debug:
1✔
981
            # Prints x_range bar to check Id, as I said voodoo
982
            print(x_range)
×
983
        plot.x_range.js_on_change(attr, CustomJS(args=dict(x_range=x_range), code=code))
1✔
984

985

986
class DataSelectionHist:
1✔
987
    """Class for an interactive data selection plot."""
988

989
    def __init__(self, name, size=600):
1✔
990
        """Class for an interactive data selection plot.
991

992
        :param name: Name of the class object instance. Needed for dynamic return, e.g. ds =
993
            DataSelectionHist("ds")
994
        :param size: Edge size of the figure in pixel.
995

996
        """
997
        raise NotImplementedError(
998
            "This function does not work with"
999
            " the latest bokeh version. If you are still using"
1000
            " this function please let us know in tech-support"
1001
            " by the 01.04.2024, else we reomve this function."
1002
        )
1003
        self.name = name
1004
        self.selection_index = None
1005
        self.size = size
1006

1007
        from bokeh.io import output_notebook
1008

1009
        output_notebook()
1010

1011
    def histogram2d(
1✔
1012
        self,
1013
        items,
1014
        xdata,
1015
        ydata,
1016
        bins,
1017
        hist_range,
1018
        x_label="X-Data",
1019
        y_label="Y-Data",
1020
        log_color_scale=True,
1021
        cmap_steps=256,
1022
        clim=(None, None),
1023
        undeflow_color=None,
1024
        overflow_color=None,
1025
        weights=1,
1026
    ):
1027
        """2d Histogram which allows to select the plotted items dynamically.
1028

1029
        Note:
1030
            You can select the data either via a box select or Lasso
1031
            select tool. The data can be returned by:
1032

1033
            ds.get_back_selected_items()
1034

1035
            Hold shift to select multiple regions.
1036

1037
        Warnings:
1038
            Depending on the number of bins the Lasso selection can
1039
            become relatively slow. The number of bins should not be
1040
            larger than 100.
1041
            The box selection performance is better.
1042

1043
        :param items: numpy.structured.array of items to be selected.
1044
            e.g. peaks or events.
1045
        :param xdata: numpy.array for xdata e.g. peaks['area']
1046
        :param ydata: same
1047
        :param bins: Integer specifying the number of bins. Currently
1048
            x and y axis must share the same binning.
1049
        :param hist_range: Tuple of x-range and y-range.
1050
        :param x_label: Label to be used for the x-axis
1051
        :param y_label: same but for y
1052
        :param log_color_scale: If true (default) use log colorscale
1053
        :param cmap_steps: Integer between 0 and 256 for stepped
1054
            colorbar.
1055
        :param clim: Tuple of color limits.
1056
        :param undeflow_color: If specified colors all bins below clim
1057
            with the corresponding color.
1058
        :param overflow_color: Same but per limit.
1059
        :param weights: If specified each bin entry is weighted by this
1060
            value. Can be either a scalar e.g. a time or an array of
1061
            weights which has the same length as the x/y data.
1062
        :return: bokeh figure instance.
1063

1064
        """
1065
        if isinstance(bins, tuple):
×
1066
            raise ValueError(
1067
                "Currently only squared bins are supported. Plase change bins into an integer."
1068
            )
1069

1070
        x_pos, y_pos = self._make_bin_positions((bins, bins), hist_range)
×
1071
        weights = np.ones(len(xdata)) * weights
×
1072

1073
        hist, hist_inds = self._hist2d_with_index(xdata, ydata, weights, self.xedges, self.yedges)
×
1074

1075
        # Define times and ids for return:
1076
        self.items = items
×
1077
        self.hist_inds = hist_inds
×
1078

1079
        colors = self._get_color(
×
1080
            hist,
1081
            cmap_steps,
1082
            log_color_scale=log_color_scale,
1083
            clim=clim,
1084
            undeflow_color=undeflow_color,
1085
            overflow_color=overflow_color,
1086
        )
1087

1088
        # Create Figure and add LassoTool:
1089
        f = bokeh.plotting.figure(
×
1090
            title="DataSelection", width=self.size, height=self.size, tools="box_select,reset,save"
1091
        )
1092

1093
        # Add hover tool, colorbar is too complictaed:
1094
        tool_tip = [("Bin Center x", "@x"), ("Bin Center y", "@y"), ("Entries", "@h")]
×
1095
        f.add_tools(
×
1096
            bokeh.models.LassoSelectTool(select_every_mousemove=False),
1097
            bokeh.models.HoverTool(tooltips=tool_tip),
1098
        )
1099

1100
        s1 = bokeh.plotting.ColumnDataSource(
×
1101
            data=dict(x=x_pos, y=y_pos, h=hist.flatten(), color=colors)
1102
        )
1103
        f.square(source=s1, size=self.size / bins, color="color", nonselection_alpha=0.3)
×
1104

1105
        f.x_range.start = self.xedges[0]
×
1106
        f.x_range.end = self.xedges[-1]
×
1107
        f.y_range.start = self.yedges[0]
×
1108
        f.y_range.end = self.yedges[-1]
×
1109
        f.xaxis.axis_label = x_label
×
1110
        f.yaxis.axis_label = y_label
×
1111

1112
        self.selection_index = None
×
1113
        s1.selected.js_on_change(
×
1114
            "indices",
1115
            bokeh.models.CustomJS(
1116
                args=dict(s1=s1),
1117
                code=f"""
1118
                var inds = cb_obj.indices;
1119
                var kernel = IPython.notebook.kernel;
1120
                kernel.execute("{self.name}.selection_index = " + inds);
1121
                """,
1122
            ),
1123
        )
1124
        return f
×
1125

1126
    def get_back_selected_items(self):
1✔
1127
        if not self.selection_index:
×
1128
            raise ValueError(
1129
                "No data selection found. Have you selected any data? "
1130
                "If yes you most likely have not intialized the DataSelctor correctly. "
1131
                'You have to callit as: my_instance_name = DataSelectionHist("my_instance_name")'
1132
            )
1133
        m = np.isin(self.hist_inds, self.selection_index)
×
1134
        return self.items[m]
×
1135

1136
    @staticmethod
1✔
1137
    @numba.njit
1✔
1138
    def _hist2d_with_index(xdata, ydata, weights, x_edges, y_edges):
1✔
1139
        n_x_bins = len(x_edges) - 1
×
1140
        n_y_bins = len(y_edges) - 1
×
1141
        res_hist_inds = np.zeros(len(xdata), dtype=np.int32)
×
1142
        res_hist = np.zeros((n_x_bins, n_y_bins), dtype=np.int64)
×
1143

1144
        # Create bin ranges:
1145
        offset = 0
×
1146
        for ind, xv in enumerate(xdata):
×
1147
            yv = ydata[ind]
×
1148
            w = weights[ind]
×
1149
            hist_ind = 0
×
1150
            found = False
×
1151
            for ind_xb, low_xb in enumerate(x_edges[:-1]):
×
1152
                high_xb = x_edges[ind_xb + 1]
×
1153

1154
                if not low_xb <= xv:
×
1155
                    hist_ind += n_y_bins
×
1156
                    continue
×
1157
                if not xv < high_xb:
×
1158
                    hist_ind += n_y_bins
×
1159
                    continue
×
1160

1161
                # Checked both bins value is in bin, so check y:
1162
                for ind_yb, low_yb in enumerate(y_edges[:-1]):
×
1163
                    high_yb = y_edges[ind_yb + 1]
×
1164

1165
                    if not low_yb <= yv:
×
1166
                        hist_ind += 1
×
1167
                        continue
×
1168
                    if not yv < high_yb:
×
1169
                        hist_ind += 1
×
1170
                        continue
×
1171

1172
                    found = True
×
1173
                    res_hist_inds[offset] = hist_ind
×
1174
                    res_hist[ind_xb, ind_yb] += w
×
1175
                    offset += 1
×
1176

1177
            # Set to -1 if not in any
1178
            if not found:
×
1179
                res_hist_inds[offset] = -1
×
1180
                offset += 1
×
1181
        return res_hist, res_hist_inds
×
1182

1183
    def _make_bin_positions(self, bins, bin_range):
1✔
1184
        """Helper function to create center positions for "histogram" markers."""
1185
        edges = []
×
1186
        for b, br in zip(bins, bin_range):
×
1187
            # Create x and y edges
1188
            d_range = br[1] - br[0]
×
1189
            edges.append(np.arange(br[0], br[1] + d_range / b, d_range / b))
×
1190

1191
        # Convert into marker positions:
1192
        xedges = edges[0]
×
1193
        yedges = edges[1]
×
1194
        self.xedges = xedges
×
1195
        self.yedges = yedges
×
1196
        x_pos = xedges[:-1] + np.diff(xedges) / 2
×
1197
        x_pos = np.repeat(x_pos, len(yedges) - 1)
×
1198

1199
        y_pos = yedges[:-1] + np.diff(yedges) / 2
×
1200
        y_pos = np.array(list(y_pos) * (len(xedges) - 1))
×
1201
        return x_pos, y_pos
×
1202

1203
    def _get_color(
1✔
1204
        self,
1205
        hist,
1206
        cmap_steps,
1207
        log_color_scale=False,
1208
        clim=(None, None),
1209
        undeflow_color=None,
1210
        overflow_color=None,
1211
    ):
1212
        """Helper function to create colorscale."""
1213
        hist = hist.flatten()
×
1214

1215
        if clim[0] and undeflow_color:
×
1216
            # If underflow is specified get indicies for underflow bins
1217
            inds_underflow = np.argwhere(hist < clim[0]).flatten()
×
1218

1219
        if clim[1] and overflow_color:
×
1220
            inds_overflow = np.argwhere(hist > clim[1]).flatten()
×
1221

1222
        # Clip data according to clim
1223
        if np.any(clim):
×
1224
            hist = np.clip(hist, clim[0], clim[1])
×
1225

1226
        self.clim = (np.min(hist), np.max(hist))
×
1227
        if log_color_scale:
×
1228
            color = np.log10(hist)
×
1229
            color /= np.max(color)
×
1230
            color *= cmap_steps - 1
×
1231
        else:
1232
            color = hist / np.max(hist)
×
1233
            color *= cmap_steps - 1
×
1234

1235
        cmap = np.array(bokeh.palettes.viridis(cmap_steps))
×
1236
        cmap = cmap[np.round(color).astype(np.int8)]
×
1237

1238
        if undeflow_color:
×
1239
            cmap[inds_underflow] = undeflow_color
×
1240

1241
        if overflow_color:
×
1242
            cmap[inds_overflow] = overflow_color
×
1243
        return cmap
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc