k4bench 0.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
k4bench/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """k4Bench — performance benchmarking for DD4hep-based simulations and reconstruction in Key4hep."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("k4bench")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
@@ -0,0 +1,19 @@
1
+ """Post-processing and visualisation for k4bench results."""
2
+
3
+ from k4bench.analysis.loader import load_event_timing, load_region_timing, load_results
4
+ from k4bench.analysis.plots import (
5
+ plot_event_memory,
6
+ plot_event_timing,
7
+ plot_region_timing,
8
+ plot_run_overview,
9
+ )
10
+
11
+ __all__ = [
12
+ "load_results",
13
+ "load_event_timing",
14
+ "load_region_timing",
15
+ "plot_run_overview",
16
+ "plot_event_timing",
17
+ "plot_event_memory",
18
+ "plot_region_timing",
19
+ ]
@@ -0,0 +1,258 @@
1
+ """Load benchmark results and per-event timing data for analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pandas as pd
9
+
10
+
11
+
12
+ def load_results(log_dir: str | Path, labels: list[str] | None = None) -> pd.DataFrame:
13
+ """Load benchmark results from a log directory into a DataFrame.
14
+
15
+ Each ``{label}_results.csv`` file written by ``k4bench`` is loaded and
16
+ concatenated into a single DataFrame.
17
+
18
+ Parameters
19
+ ----------
20
+ log_dir : str or Path
21
+ Directory containing ``*_results.csv`` files.
22
+ labels : list[str] or None
23
+ Load only these run labels. Loads all ``*_results.csv`` files when ``None``.
24
+
25
+ Returns
26
+ -------
27
+ pd.DataFrame
28
+ One row per run. Float columns are cast to ``float64``;
29
+ integer columns that may contain NaN use nullable ``Int64``.
30
+ """
31
+ log_dir = Path(log_dir)
32
+ _suffix = "_results.csv"
33
+
34
+ if labels is not None:
35
+ candidates = [(log_dir / f"{lbl}{_suffix}", lbl) for lbl in labels]
36
+ missing = [lbl for path, lbl in candidates if not path.exists()]
37
+ if missing:
38
+ raise ValueError(f"Missing result files for labels: {missing}")
39
+ paths = [path for path, _ in candidates if path.exists()]
40
+ else:
41
+ paths = sorted(log_dir.glob(f"*{_suffix}"))
42
+
43
+ if not paths:
44
+ raise ValueError(f"No *_results.csv files found in '{log_dir}'.")
45
+
46
+ df = pd.concat([pd.read_csv(p) for p in paths], ignore_index=True)
47
+
48
+ float_cols = [
49
+ "wall_time_s", "user_cpu_s", "sys_cpu_s",
50
+ "peak_rss_mb", "output_size_mb", "events_per_sec",
51
+ ]
52
+ int_cols = [
53
+ "returncode", "n_events",
54
+ "major_page_faults", "voluntary_ctx_switches", "involuntary_ctx_switches",
55
+ ]
56
+
57
+ for col in float_cols:
58
+ if col in df.columns:
59
+ df[col] = pd.to_numeric(df[col], errors="coerce")
60
+
61
+ for col in int_cols:
62
+ if col in df.columns:
63
+ df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int64")
64
+
65
+ return df
66
+
67
+
68
+ def load_event_timing(
69
+ log_dir: str | Path,
70
+ labels: list[str] | None = None,
71
+ ) -> dict[str, pd.DataFrame]:
72
+ """Load per-event timing JSON files from a log directory.
73
+
74
+ Each ``{label}_events.json`` file written by the k4BenchTimingAction
75
+ plugin is parsed into a DataFrame.
76
+
77
+ Parameters
78
+ ----------
79
+ log_dir : str or Path
80
+ Directory containing ``*_events.json`` files.
81
+ labels : list[str] or None
82
+ Load only these run labels. If None, all ``*_events.json`` files
83
+ in *log_dir* are loaded.
84
+
85
+ Returns
86
+ -------
87
+ dict[str, pd.DataFrame]
88
+ Maps label → DataFrame with columns
89
+ ``event_number``, ``event_time_s``, ``rss_begin_mb``,
90
+ ``rss_end_mb``, ``rss_delta_mb``.
91
+ """
92
+ log_dir = Path(log_dir)
93
+ _suffix = "_events.json"
94
+
95
+ if labels is not None:
96
+ candidates = [(log_dir / f"{lbl}{_suffix}", lbl) for lbl in labels]
97
+ else:
98
+ candidates = [
99
+ (p, p.name[: -len(_suffix)])
100
+ for p in sorted(log_dir.glob(f"*{_suffix}"))
101
+ ]
102
+
103
+ if labels is not None:
104
+ missing_files = [lbl for path, lbl in candidates if not path.exists()]
105
+ if missing_files:
106
+ raise ValueError(f"Missing event files for labels: {missing_files}")
107
+
108
+ out: dict[str, pd.DataFrame] = {}
109
+ for path, label in candidates:
110
+ if not path.exists():
111
+ continue
112
+ with path.open() as f:
113
+ raw = json.load(f)
114
+ _required = ["event_numbers", "event_times_s", "event_rss_begin_mb", "event_rss_end_mb"]
115
+ _missing = [k for k in _required if k not in raw]
116
+ if _missing:
117
+ raise ValueError(f"{path} missing keys: {_missing}")
118
+ lengths = {k: len(raw[k]) for k in _required}
119
+ if len(set(lengths.values())) > 1:
120
+ raise ValueError(f"{path} has mismatched array lengths: {lengths}")
121
+ df = pd.DataFrame(
122
+ {
123
+ "event_number": raw["event_numbers"],
124
+ "event_time_s": raw["event_times_s"],
125
+ "rss_begin_mb": raw["event_rss_begin_mb"],
126
+ "rss_end_mb": raw["event_rss_end_mb"],
127
+ }
128
+ )
129
+ df["rss_delta_mb"] = df["rss_end_mb"] - df["rss_begin_mb"]
130
+ out[label] = df
131
+
132
+ return out
133
+
134
+
135
+ def load_region_timing(
136
+ log_dir: str | Path,
137
+ labels: list[str] | None = None,
138
+ ) -> dict[str, dict]:
139
+ """Load per-region timing JSON files from a log directory.
140
+
141
+ Each ``{label}_regions.json`` file written by the k4BenchRegionTimingAction
142
+ plugin is parsed into structured data.
143
+
144
+ Parameters
145
+ ----------
146
+ log_dir : str or Path
147
+ Directory containing ``*_regions.json`` files.
148
+ labels : list[str] or None
149
+ Load only these run labels. If ``None``, all ``*_regions.json`` files
150
+ in *log_dir* are loaded.
151
+
152
+ Returns
153
+ -------
154
+ dict[str, dict]
155
+ Maps label → dict with keys:
156
+
157
+ - ``"meta"``: dict with schema_version, timer, overhead_ns, detectors,
158
+ lv_counts.
159
+ - ``"events"``: DataFrame with columns ``event_number``,
160
+ ``event_wall_s``, ``event_region_sum_s``, ``event_unaccounted_s``.
161
+ - ``"at_location"``: DataFrame indexed by ``event_number``, one column
162
+ per top-level detector (seconds), time charged to where the Geant4
163
+ step physically occurred.
164
+ - ``"by_birth"``: same shape as ``at_location``, time charged to the
165
+ detector where the primary track was created.
166
+ """
167
+ log_dir = Path(log_dir)
168
+ _suffix = "_regions.json"
169
+
170
+ if labels is not None:
171
+ candidates = [(log_dir / f"{lbl}{_suffix}", lbl) for lbl in labels]
172
+ else:
173
+ candidates = [
174
+ (p, p.name[: -len(_suffix)])
175
+ for p in sorted(log_dir.glob(f"*{_suffix}"))
176
+ ]
177
+
178
+ if labels is not None:
179
+ missing = [lbl for path, lbl in candidates if not path.exists()]
180
+ if missing:
181
+ raise ValueError(f"Missing region files for labels: {missing}")
182
+
183
+ out: dict[str, dict] = {}
184
+ for path, label in candidates:
185
+ if not path.exists():
186
+ continue
187
+ with path.open() as f:
188
+ raw = json.load(f)
189
+
190
+ _required = [
191
+ "event_numbers", "event_wall_seconds",
192
+ "event_region_sum_seconds", "event_unaccounted_seconds",
193
+ "at_location_seconds", "by_birth_seconds",
194
+ ]
195
+ _missing = [k for k in _required if k not in raw]
196
+ if _missing:
197
+ raise ValueError(f"{path} missing keys: {_missing}")
198
+
199
+ n_ev = len(raw["event_numbers"])
200
+ if len(set(raw["event_numbers"])) != n_ev:
201
+ raise ValueError(f"{path}: event_numbers contains duplicates")
202
+ for k in ["event_wall_seconds", "event_region_sum_seconds",
203
+ "event_unaccounted_seconds", "at_location_seconds", "by_birth_seconds"]:
204
+ if len(raw[k]) != n_ev:
205
+ raise ValueError(f"{path}: array length mismatch for '{k}'")
206
+
207
+ events_df = pd.DataFrame({
208
+ "event_number": raw["event_numbers"],
209
+ "event_wall_s": raw["event_wall_seconds"],
210
+ "event_region_sum_s": raw["event_region_sum_seconds"],
211
+ "event_unaccounted_s": raw["event_unaccounted_seconds"],
212
+ })
213
+
214
+ ev_index = pd.Index(raw["event_numbers"], name="event_number")
215
+ at_loc_df = pd.DataFrame(raw["at_location_seconds"], index=ev_index).fillna(0.0)
216
+ by_birth_df = pd.DataFrame(raw["by_birth_seconds"], index=ev_index).fillna(0.0)
217
+
218
+ declared = raw.get("indexed_top_level_detectors", [])
219
+ if declared:
220
+ extra_at = [c for c in at_loc_df.columns if c not in declared]
221
+ extra_by = [c for c in by_birth_df.columns if c not in declared]
222
+ at_loc_df = at_loc_df.reindex( columns=declared + extra_at, fill_value=0.0)
223
+ by_birth_df = by_birth_df.reindex( columns=declared + extra_by, fill_value=0.0)
224
+
225
+ # Step counts per detector per event (interval_counts field, optional).
226
+ # Columns include all detector names plus "unattributed" for steps that
227
+ # could not be assigned to a top-level detector element.
228
+ raw_steps = raw.get("interval_counts", [])
229
+ if raw_steps and len(raw_steps) == n_ev:
230
+ steps_df: pd.DataFrame | None = (
231
+ pd.DataFrame(raw_steps, index=ev_index).fillna(0).astype(float)
232
+ )
233
+ else:
234
+ steps_df = None
235
+
236
+ out[label] = {
237
+ "meta": {
238
+ "schema_version": raw.get("schema_version", 1),
239
+ "attribution_method": raw.get("attribution", "dd4hep_top_level_detelement"),
240
+ "timer": raw.get("timer", "unknown"),
241
+ "overhead_ns": raw.get("per_step_timer_overhead_ns"),
242
+ "detectors": raw.get("indexed_top_level_detectors", []),
243
+ "lv_counts": raw.get("indexed_top_level_detector_lv_counts", {}),
244
+ },
245
+ "events": events_df,
246
+ "at_location": at_loc_df,
247
+ "by_birth": by_birth_df,
248
+ "steps": steps_df, # None when interval_counts absent in JSON
249
+ }
250
+
251
+ if not out:
252
+ if labels is not None:
253
+ raise ValueError(
254
+ f"No region files found for labels={labels} in '{log_dir}'."
255
+ )
256
+ raise ValueError(f"No *_regions.json files found in '{log_dir}'.")
257
+
258
+ return out
@@ -0,0 +1,28 @@
1
+ """Plotly plotting functions for k4bench results.
2
+
3
+ All functions return a :class:`~plotly.graph_objects.Figure`.
4
+ In Jupyter the figure renders inline automatically; call ``fig.show()``
5
+ to display it explicitly, or ``fig.write_html("out.html")`` to export.
6
+
7
+ Typical notebook usage::
8
+
9
+ from k4bench.analysis import load_results, plot_run_overview, plot_event_timing
10
+
11
+ plot_run_overview("logs/")
12
+ plot_event_timing("logs/")
13
+ """
14
+
15
+ from .event import plot_event_memory, plot_event_timing
16
+ from .overview import plot_run_overview
17
+ from .region import plot_region_timing
18
+ from ._theme import PALETTE
19
+ from ._utils import _compute_core_range # re-exported: used in tests
20
+
21
+ __all__ = [
22
+ "plot_run_overview",
23
+ "plot_event_timing",
24
+ "plot_event_memory",
25
+ "plot_region_timing",
26
+ "PALETTE",
27
+ "_compute_core_range",
28
+ ]
@@ -0,0 +1,46 @@
1
+ """Visual style constants shared across all plot modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _TEMPLATE = "plotly_white"
6
+
7
+ _BLUE = "#1f77b4"
8
+ _RED = "#d62728"
9
+
10
+ _PALETTE = [
11
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
12
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
13
+ ]
14
+
15
+ PALETTE = _PALETTE
16
+
17
+ _METRIC_UNITS: dict[str, str] = {
18
+ "wall_time_s": "(s)",
19
+ "peak_rss_mb": "(MB)",
20
+ "user_cpu_s": "(s)",
21
+ "sys_cpu_s": "(s)",
22
+ "events_per_sec": "(ev/s)",
23
+ "output_size_mb": "(MB)",
24
+ }
25
+
26
+ _UNACCOUNTED_COLOR = "#999999"
27
+ _OTHER_COLOR = "#d0d0d0"
28
+
29
+
30
+ def _hex_to_rgba(color: str, alpha: float) -> str:
31
+ """Convert a hex or rgb() color string to an rgba() string."""
32
+ color = color.strip()
33
+ if color.startswith("rgba("):
34
+ # Already has alpha — replace it
35
+ inner = color[5:].rstrip(")")
36
+ parts = inner.rsplit(",", 1)
37
+ return f"rgba({parts[0]},{alpha})"
38
+ if color.startswith("rgb("):
39
+ inner = color[4:].rstrip(")")
40
+ return f"rgba({inner},{alpha})"
41
+ # Hex (#RRGGBB or #RGB)
42
+ h = color.lstrip("#")
43
+ if len(h) == 3:
44
+ h = h[0]*2 + h[1]*2 + h[2]*2
45
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
46
+ return f"rgba({r},{g},{b},{alpha})"
@@ -0,0 +1,90 @@
1
+ """Reusable Plotly trace builders shared across plot modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import plotly.graph_objects as go
7
+
8
+ from ._theme import _BLUE, _PALETTE, _hex_to_rgba
9
+
10
+
11
+ def _histogram_traces(
12
+ arrays: dict[str, np.ndarray],
13
+ common_edges: np.ndarray,
14
+ label_list: list[str],
15
+ alpha: float,
16
+ show_legend: bool,
17
+ palette: list[str] | None = None,
18
+ ) -> list[go.Bar]:
19
+ """Return one histogram Bar trace per label using shared bin edges."""
20
+ centers = 0.5 * (common_edges[:-1] + common_edges[1:])
21
+ widths = common_edges[1:] - common_edges[:-1]
22
+ _pal = palette if palette is not None else _PALETTE
23
+ traces = []
24
+ for i, lbl in enumerate(label_list):
25
+ color = _BLUE if len(label_list) == 1 else _pal[i % len(_pal)]
26
+ counts, _ = np.histogram(arrays[lbl], bins=common_edges)
27
+ traces.append(go.Bar(
28
+ x=centers,
29
+ y=counts,
30
+ width=widths,
31
+ name=lbl,
32
+ legendgroup=lbl,
33
+ marker_color=_hex_to_rgba(color, alpha),
34
+ marker_line_width=0,
35
+ showlegend=show_legend,
36
+ hovertemplate=f"<b>{lbl}</b><br>bin centre: %{{x:.4g}}<br>count: %{{y}}<extra></extra>",
37
+ ))
38
+ return traces
39
+
40
+
41
+ def _format_delta_cell(mean: float, sem: float, ref_mean: float, ref_sem: float) -> str:
42
+ """Return a formatted Δμ ± δ(Δμ) string for use as a stats-table cell.
43
+
44
+ Returns ``"—"`` for the reference run (mean == ref_mean) and
45
+ ``"undefined"`` when ref_mean is zero to avoid division by zero.
46
+ """
47
+ if mean == ref_mean:
48
+ return "—"
49
+ if ref_mean == 0:
50
+ return "undefined"
51
+ delta_pct = (mean - ref_mean) / ref_mean * 100
52
+ delta_err = (100.0 / ref_mean) * np.sqrt(sem**2 + (mean / ref_mean * ref_sem) ** 2)
53
+ sign = "+" if delta_pct >= 0 else ""
54
+ return f"{sign}{delta_pct:.2f}% ± {delta_err:.2f}%"
55
+
56
+
57
+ def _stats_table_trace(
58
+ table_rows: list[list[str]],
59
+ ref_label: str,
60
+ unit_label: str,
61
+ label_list: list[str],
62
+ ) -> go.Table:
63
+ """Build a Plotly Table trace from per-run stats rows."""
64
+ headers = [
65
+ "Run",
66
+ f"μ ± SEM ({unit_label})",
67
+ f"σ ({unit_label})",
68
+ f"Δμ ± δ(Δμ) [ref: {ref_label}]",
69
+ ]
70
+ n = len(table_rows)
71
+ row_colors = [_hex_to_rgba(_PALETTE[i % len(_PALETTE)], 0.12) for i in range(n)]
72
+ col_data = [list(col) for col in zip(*table_rows)] if table_rows else [[] for _ in headers]
73
+
74
+ return go.Table(
75
+ header=dict(
76
+ values=[f"<b>{h}</b>" for h in headers],
77
+ fill_color="#d4d4d4",
78
+ align="center",
79
+ font=dict(size=11, color="#222222"),
80
+ line_color="white",
81
+ ),
82
+ cells=dict(
83
+ values=col_data,
84
+ fill_color=[row_colors],
85
+ align="center",
86
+ font=dict(size=10, color="#333333"),
87
+ line_color="white",
88
+ height=24,
89
+ ),
90
+ )
@@ -0,0 +1,196 @@
1
+ """Pure-data helpers: data loading/normalisation, outlier detection, region utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from ..loader import load_event_timing, load_region_timing, load_results
11
+
12
+ _DEFAULT_EXCLUDE_EVENTS = [0]
13
+
14
+
15
+ def _path_name(p: str | Path) -> str:
16
+ """Return the final path component, stripping trailing slashes first."""
17
+ return Path(str(p).rstrip("/")).name
18
+
19
+ _OUTLIER_FRACTION_WARN = 0.05
20
+ _OUTLIER_EXTREME_RATIO = 5.0
21
+
22
+
23
+ def _compute_stats(data: np.ndarray) -> tuple[float, float, float, float]:
24
+ """Return (mean, std, sem, se_std) with se_std = std / sqrt(2*(n-1))."""
25
+ data = data[~np.isnan(data)]
26
+ n = len(data)
27
+ mean = float(data.mean()) if n > 0 else float("nan")
28
+ if n > 1:
29
+ std = float(data.std(ddof=1))
30
+ sem = std / np.sqrt(n)
31
+ se_std = std / np.sqrt(2 * (n - 1))
32
+ else:
33
+ std = sem = se_std = float("nan")
34
+ return mean, std, sem, se_std
35
+
36
+
37
+ def _ensure_df(results: pd.DataFrame | str | Path | list) -> pd.DataFrame:
38
+ """Accept a DataFrame, a single log-dir path, or a list of paths."""
39
+ if isinstance(results, pd.DataFrame):
40
+ return results
41
+ if isinstance(results, list):
42
+ frames = []
43
+ for path in results:
44
+ df = load_results(path).copy()
45
+ prefix = _path_name(path)
46
+ df["label"] = df["label"].astype(str).apply(
47
+ lambda lbl, p=prefix: lbl if lbl.startswith(f"{p}/") else f"{p}/{lbl}"
48
+ )
49
+ frames.append(df)
50
+ return pd.concat(frames, ignore_index=True)
51
+ return load_results(results)
52
+
53
+
54
+ def _ensure_event_data(
55
+ source: dict[str, pd.DataFrame] | str | Path | list,
56
+ labels: list[str] | None = None,
57
+ ) -> dict[str, pd.DataFrame]:
58
+ """Accept a pre-loaded dict, a single log-dir path, or a list of paths.
59
+
60
+ ``labels`` matching supports both exact keys and suffix matching: a key
61
+ ``"dir/run"`` matches the label ``"run"``. When two labels share the same
62
+ suffix, pass the full prefixed key to disambiguate.
63
+ """
64
+ if isinstance(source, dict):
65
+ if labels is not None:
66
+ return {k: v for k, v in source.items() if k in labels}
67
+ return source
68
+ if isinstance(source, list):
69
+ out: dict[str, pd.DataFrame] = {}
70
+ for path in source:
71
+ prefix = _path_name(path)
72
+ for lbl, df in load_event_timing(path).items():
73
+ key = lbl if lbl.startswith(f"{prefix}/") else f"{prefix}/{lbl}"
74
+ if key in out:
75
+ raise ValueError(
76
+ f"Duplicate label '{key}': two source paths share the directory name "
77
+ f"'{prefix}'. Rename the directories to disambiguate."
78
+ )
79
+ out[key] = df
80
+ if labels is not None:
81
+ out = {k: v for k, v in out.items()
82
+ if k in labels or any(k.endswith(f"/{w}") for w in labels)}
83
+ return out
84
+ return load_event_timing(source, labels=labels)
85
+
86
+
87
+ def _ensure_region_data(
88
+ source: dict[str, dict] | str | Path | list[str | Path],
89
+ labels: list[str] | None = None,
90
+ ) -> dict[str, dict]:
91
+ """Accept a pre-loaded dict, a single log-dir path, or a list of paths."""
92
+ if isinstance(source, dict):
93
+ if labels is not None:
94
+ return {k: v for k, v in source.items() if k in labels}
95
+ return source
96
+ if isinstance(source, list):
97
+ out: dict[str, dict] = {}
98
+ for path in source:
99
+ prefix = _path_name(path)
100
+ for lbl, data in load_region_timing(path).items():
101
+ key = lbl if lbl.startswith(f"{prefix}/") else f"{prefix}/{lbl}"
102
+ if key in out:
103
+ raise ValueError(
104
+ f"Duplicate label '{key}': two source paths share the directory name "
105
+ f"'{prefix}'. Rename the directories to disambiguate."
106
+ )
107
+ out[key] = data
108
+ if labels is not None:
109
+ out = {k: v for k, v in out.items()
110
+ if k in labels or any(k.endswith(f"/{w}") for w in labels)}
111
+ return out
112
+ return load_region_timing(source, labels=labels)
113
+
114
+
115
+ def _detector_title(source: object) -> str | None:
116
+ """Return a display name from path(s); None for pre-loaded data."""
117
+ if isinstance(source, (str, Path)):
118
+ return _path_name(source)
119
+ if isinstance(source, list) and source:
120
+ return " vs ".join(_path_name(s) for s in source)
121
+ return None
122
+
123
+
124
+ def _default_baseline(labels: list[str]) -> str:
125
+ return sorted(labels)[0]
126
+
127
+
128
+ def _matches_baseline(label: str, baseline_label: str | None) -> bool:
129
+ if baseline_label is None:
130
+ return False
131
+ return label == baseline_label or label.endswith(f"/{baseline_label}")
132
+
133
+
134
+ def _compute_core_range(
135
+ data: np.ndarray,
136
+ threshold: float = 3.5,
137
+ ) -> tuple[tuple[float, float], int]:
138
+ """Return an x-range that excludes outliers and the number of clipped points.
139
+
140
+ Uses the modified Z-score (Iglewicz & Hoaglin, 1993) with MAD as the
141
+ spread estimate, which is robust for skewed or heavy-tailed distributions.
142
+ """
143
+ median = float(np.median(data))
144
+ mad = float(np.median(np.abs(data - median)))
145
+
146
+ if mad > 0:
147
+ modified_z = 0.6745 * np.abs(data - median) / mad
148
+ mask = modified_z <= threshold
149
+ core = data[mask]
150
+ n_clipped = int((~mask).sum())
151
+ else:
152
+ core = data
153
+ n_clipped = 0
154
+
155
+ if len(core) == 0:
156
+ core = data
157
+ n_clipped = 0
158
+
159
+ x_min, x_max = float(core.min()), float(core.max())
160
+ margin = 0.05 * (x_max - x_min) if x_max > x_min else 0.01 * abs(x_max)
161
+ x_min = max(0.0, x_min - margin)
162
+ x_max = x_max + margin
163
+ return (x_min, x_max), n_clipped
164
+
165
+
166
+ def _region_top_n(
167
+ time_df: pd.DataFrame,
168
+ top_n: int,
169
+ ) -> tuple[list[str], list[str]]:
170
+ """Return (top_dets, all_dets_sorted) by mean time descending, skipping zero-time columns."""
171
+ means = time_df.mean()
172
+ active = means[means > 0].sort_values(ascending=False)
173
+ all_sorted = active.index.tolist()
174
+ return all_sorted[:top_n], all_sorted
175
+
176
+
177
+ def _build_stacked_arrays(
178
+ time_df: pd.DataFrame,
179
+ top_dets: list[str],
180
+ all_dets_sorted: list[str],
181
+ ) -> dict[str, np.ndarray]:
182
+ """Build per-detector time arrays; detectors outside top_dets are grouped as 'Other'."""
183
+ n = len(time_df)
184
+ arrays: dict[str, np.ndarray] = {}
185
+ for det in top_dets:
186
+ arrays[det] = time_df[det].to_numpy() if det in time_df.columns else np.zeros(n)
187
+
188
+ other_dets = [d for d in all_dets_sorted if d not in top_dets]
189
+ extra_dets = [d for d in time_df.columns if d not in top_dets and d not in all_dets_sorted]
190
+ if other_dets or extra_dets:
191
+ other_arr = np.zeros(n)
192
+ for det in other_dets + extra_dets:
193
+ if det in time_df.columns:
194
+ other_arr = other_arr + time_df[det].to_numpy()
195
+ arrays["Other"] = other_arr
196
+ return arrays