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 +8 -0
- k4bench/analysis/__init__.py +19 -0
- k4bench/analysis/loader.py +258 -0
- k4bench/analysis/plots/__init__.py +28 -0
- k4bench/analysis/plots/_theme.py +46 -0
- k4bench/analysis/plots/_traces.py +90 -0
- k4bench/analysis/plots/_utils.py +196 -0
- k4bench/analysis/plots/event.py +512 -0
- k4bench/analysis/plots/overview.py +199 -0
- k4bench/analysis/plots/region.py +419 -0
- k4bench/benchmark/__init__.py +0 -0
- k4bench/benchmark/ddsim.py +337 -0
- k4bench/cli.py +237 -0
- k4bench/geometry/__init__.py +0 -0
- k4bench/geometry/patcher.py +406 -0
- k4bench/geometry/scanner.py +135 -0
- k4bench/plugin/__init__.py +0 -0
- k4bench/plugin/runtime.py +144 -0
- k4bench/results/__init__.py +0 -0
- k4bench/results/model.py +103 -0
- k4bench/results/reporter.py +73 -0
- k4bench/runner/__init__.py +0 -0
- k4bench/runner/executor.py +282 -0
- k4bench/runner/parser.py +119 -0
- k4bench-0.0.13.dist-info/METADATA +115 -0
- k4bench-0.0.13.dist-info/RECORD +30 -0
- k4bench-0.0.13.dist-info/WHEEL +5 -0
- k4bench-0.0.13.dist-info/entry_points.txt +2 -0
- k4bench-0.0.13.dist-info/licenses/LICENSE +21 -0
- k4bench-0.0.13.dist-info/top_level.txt +1 -0
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
|