Skip to content

Commit b4c01db

Browse files
committed
fixed analyzer E. added vtk export of api
1 parent fef5b9c commit b4c01db

11 files changed

Lines changed: 205 additions & 10 deletions

analyzer/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ The digitized B2 output includes columns like:
6363
hitn, pid, tid, E, time, totEdep
6464
```
6565

66+
The true-info output includes tracking columns like:
67+
68+
```text
69+
processName, avgTime, avgx, avgy, avgz, hitn, pid, tid, totalEDeposited
70+
```
71+
72+
When the matching digitized CSV is available, the analyzer also adds `E` to
73+
true-info tables by matching rows on event, detector, hit, PID, and track ID.
74+
In that case `E` is the track total energy, while `totalEDeposited` remains
75+
the deposited energy.
76+
6677
The ROOT streamer writes one ROOT file per worker thread. For one thread and
6778
`filename: b2`, the file is typically:
6879

-715 Bytes
Binary file not shown.
-312 Bytes
Binary file not shown.
-3.42 KB
Binary file not shown.
-4.99 KB
Binary file not shown.
-5.74 KB
Binary file not shown.
-8.62 KB
Binary file not shown.

analyzer/plotting.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
"totalE": "Total Energy (MeV)",
2525
}
2626

27+
VARIABLE_ALIASES: Mapping[str, tuple[str, ...]] = {
28+
"E": ("totalE", "etot"),
29+
"totalEDeposited": ("totEdep",),
30+
"etot": ("totalE",),
31+
}
32+
2733

2834
def plot_variable(
2935
output: GemcOutput | pd.DataFrame,
@@ -73,9 +79,10 @@ def plot_histogram(
7379
) -> tuple[plt.Figure, plt.Axes]:
7480
"""Plot one numeric variable as a histogram, optionally grouped by a column."""
7581

76-
if variable not in frame.columns:
77-
available = ", ".join(frame.columns)
78-
raise KeyError(f"Column '{variable}' not found. Available columns: {available}")
82+
variable = _resolve_variable(frame, variable)
83+
84+
if frame.empty:
85+
raise ValueError("Selected data table is empty.")
7986

8087
values = pd.to_numeric(frame[variable], errors="coerce").dropna()
8188
if values.empty:
@@ -124,3 +131,13 @@ def plot_histogram(
124131
plt.show()
125132

126133
return fig, ax
134+
135+
136+
def _resolve_variable(frame: pd.DataFrame, variable: str) -> str:
137+
if variable not in frame.columns:
138+
for alias in VARIABLE_ALIASES.get(variable, ()):
139+
if alias in frame.columns:
140+
return alias
141+
available = ", ".join(frame.columns)
142+
raise KeyError(f"Column '{variable}' not found. Available columns: {available}")
143+
return variable

analyzer/readers.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ def read_csv_output(path: str | Path) -> GemcOutput:
4444
output = GemcOutput(source=str(path))
4545

4646
if path.suffix == ".csv":
47-
frame = _read_csv(path)
47+
frame = _read_csv_with_fallback(path)
4848
stream = _classify_csv_stream(path)
4949
if stream == "true_info":
50+
frame = _with_track_energy(frame, _matching_digitized_csv(path))
5051
output.true_info[path.stem] = frame
5152
else:
5253
output.digitized[path.stem] = frame
@@ -55,7 +56,7 @@ def read_csv_output(path: str | Path) -> GemcOutput:
5556
for stream, suffix in CSV_STREAM_SUFFIXES.items():
5657
candidate = Path(str(path) + suffix)
5758
if candidate.exists():
58-
frame = _read_csv(candidate)
59+
frame = _read_csv_with_fallback(candidate)
5960
if stream == "true_info":
6061
output.true_info["csv"] = frame
6162
else:
@@ -64,6 +65,7 @@ def read_csv_output(path: str | Path) -> GemcOutput:
6465
if not output.true_info and not output.digitized:
6566
raise FileNotFoundError(f"No GEMC CSV files found for '{path}'.")
6667

68+
_add_track_energy_columns(output)
6769
return output
6870

6971

@@ -104,6 +106,61 @@ def _read_csv(path: Path) -> pd.DataFrame:
104106
return pd.read_csv(path, sep=",", skipinitialspace=True)
105107

106108

109+
def _read_csv_with_fallback(path: Path) -> pd.DataFrame:
110+
try:
111+
return _read_csv(path)
112+
except pd.errors.EmptyDataError:
113+
fallback = _thread_csv_fallback(path)
114+
if fallback is not None:
115+
return _read_csv(fallback)
116+
raise
117+
118+
119+
def _thread_csv_fallback(path: Path) -> Path | None:
120+
"""
121+
Return the run-level CSV matching an empty worker-thread CSV, if present.
122+
123+
Some accumulated digitizations, such as dosimeter output, write their final
124+
table to ``name_digitized.csv`` while the corresponding ``name_t0_digitized.csv``
125+
worker file exists but is empty.
126+
"""
127+
name = path.name
128+
if "_t0_" not in name:
129+
return None
130+
131+
candidate = path.with_name(name.replace("_t0_", "_", 1))
132+
if candidate.exists() and candidate.stat().st_size > 0:
133+
return candidate
134+
return None
135+
136+
137+
def _matching_digitized_csv(path: Path) -> pd.DataFrame | None:
138+
candidate = path.with_name(path.name.replace(CSV_STREAM_SUFFIXES["true_info"], CSV_STREAM_SUFFIXES["digitized"]))
139+
if candidate == path or not candidate.exists() or candidate.stat().st_size == 0:
140+
return None
141+
return _read_csv_with_fallback(candidate)
142+
143+
144+
def _add_track_energy_columns(output: GemcOutput) -> None:
145+
for name, frame in list(output.true_info.items()):
146+
digitized = output.digitized.get(name)
147+
if digitized is None and len(output.digitized) == 1:
148+
digitized = next(iter(output.digitized.values()))
149+
output.true_info[name] = _with_track_energy(frame, digitized)
150+
151+
152+
def _with_track_energy(frame: pd.DataFrame, digitized: pd.DataFrame | None) -> pd.DataFrame:
153+
if "E" in frame.columns or digitized is None or "E" not in digitized.columns:
154+
return frame
155+
156+
keys = [key for key in ("evn", "thread_id", "detector", "hitn", "pid", "tid") if key in frame.columns and key in digitized.columns]
157+
if not keys:
158+
return frame
159+
160+
energy = digitized[keys + ["E"]].drop_duplicates(subset=keys)
161+
return frame.merge(energy, on=keys, how="left")
162+
163+
107164
def _classify_csv_stream(path: Path) -> str:
108165
name = path.name
109166
if name.endswith(CSV_STREAM_SUFFIXES["true_info"]):

api/gconfiguration.py

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
# python
2121
import sqlite3
22-
import os, argparse, sys
22+
import os, argparse, sys, json, zipfile
2323

2424
from dataclasses import dataclass
2525
from typing import Optional, Tuple
@@ -78,6 +78,10 @@ def get_arguments(argv=None):
7878
parser.add_argument("-pvh", "--height", type=int, default=800, help="Set plotter height")
7979
parser.add_argument("-pvx", "--x", type=int, default=0, help="Set plotter x position")
8080
parser.add_argument("-pvy", "--y", type=int, default=0, help="Set plotter y position")
81+
parser.add_argument("-pvvtk", "--pyvista-vtksz", default=None,
82+
help="Export PyVista scene as a VTK.js .vtksz file; .vtksz is added if omitted")
83+
parser.add_argument("-pvz", "--pyvista-vtksz-zoom", type=float, default=0.25,
84+
help="Initial VTK.js scene zoom for --pyvista-vtksz; smaller values zoom out")
8185
parser.add_argument("-axes", "--add_axes_at_zero", action="store_true",
8286
help="Add 10cm axes at (0, 0, 0)")
8387

@@ -103,6 +107,7 @@ def __init__(
103107
args=None,
104108
enable_pyvista: Optional[bool] = None,
105109
use_background_plotter: bool = None,
110+
pyvista_vtksz: Optional[str] = None,
106111
):
107112
self.args = get_arguments() # expose args to scripts that use this class
108113
self.experiment = experiment
@@ -133,12 +138,21 @@ def __init__(
133138
# pyvista
134139
# CLI background flag
135140
background_flag = bool(getattr(self.args, "pyvista_background", False))
141+
self.pyvista_vtksz = pyvista_vtksz if pyvista_vtksz is not None else getattr(
142+
self.args, "pyvista_vtksz", None
143+
)
144+
self.pyvista_vtksz_zoom = getattr(self.args, "pyvista_vtksz_zoom", 0.25)
145+
self.show_pyvista_window = (
146+
self.args.pyvista
147+
or background_flag
148+
or (enable_pyvista is True and not self.pyvista_vtksz)
149+
)
136150

137151
# Decide if PyVista is wanted:
138152
# - programmatic enable_pyvista overrides CLI (True/False)
139-
# - otherwise: --pyvista OR --pvb imply pyvista
153+
# - otherwise: --pyvista, --pvb, or --pyvista-vtksz imply pyvista
140154
if enable_pyvista is None:
141-
wants_pyvista = self.args.pyvista or background_flag
155+
wants_pyvista = self.args.pyvista or background_flag or bool(self.pyvista_vtksz)
142156
else:
143157
wants_pyvista = enable_pyvista
144158

@@ -159,6 +173,7 @@ def __init__(
159173

160174
self._plotter: Optional[object] = None
161175
self._camera_initialized = False
176+
self._pyvista_vtksz_exported = False
162177

163178
# Set the initial variation and file names.
164179
#
@@ -225,6 +240,92 @@ def close(self):
225240
self._plotter.close()
226241
self._plotter = None
227242

243+
def export_vtksz(self, filename: Optional[str] = None, zoom: Optional[float] = None):
244+
"""Export the current PyVista scene as a VTK.js OfflineLocalView file."""
245+
if not self.use_pyvista:
246+
return None
247+
248+
output = filename if filename is not None else self.pyvista_vtksz
249+
if not output:
250+
return None
251+
if not output.endswith(".vtksz"):
252+
output = f"{output}.vtksz"
253+
254+
p = self.plotter
255+
if p is None:
256+
return None
257+
258+
self._configure_camera_from_bounds()
259+
try:
260+
p.camera.view_angle = 70.0
261+
except Exception:
262+
pass
263+
try:
264+
p.render()
265+
except Exception:
266+
pass
267+
268+
try:
269+
argv = sys.argv
270+
sys.argv = [argv[0]]
271+
exported = p.export_vtksz(output)
272+
except ImportError as e:
273+
sys.exit(f"{GColors.RED}Error exporting PyVista VTK.js scene: {e}{GColors.END}")
274+
finally:
275+
sys.argv = argv
276+
277+
output_zoom = self.pyvista_vtksz_zoom if zoom is None else zoom
278+
self._adjust_vtksz_camera(exported, zoom=output_zoom)
279+
280+
self._pyvista_vtksz_exported = True
281+
print(f" ❖ Exported PyVista VTK.js scene: {exported}")
282+
return exported
283+
284+
def _adjust_vtksz_camera(self, filename, zoom: float = 0.25):
285+
if zoom <= 0:
286+
return
287+
288+
try:
289+
with zipfile.ZipFile(filename, "r") as zf:
290+
entries = {name: zf.read(name) for name in zf.namelist()}
291+
except (OSError, zipfile.BadZipFile):
292+
return
293+
294+
if "index.json" not in entries:
295+
return
296+
297+
try:
298+
data = json.loads(entries["index.json"].decode("utf-8"))
299+
except (UnicodeDecodeError, json.JSONDecodeError):
300+
return
301+
302+
def adjust_node(node):
303+
if not isinstance(node, dict):
304+
return
305+
306+
if "Camera" in str(node.get("type", "")):
307+
properties = node.get("properties", {})
308+
position = properties.get("position")
309+
focal_point = properties.get("focalPoint")
310+
if position and focal_point and len(position) == 3 and len(focal_point) == 3:
311+
properties["position"] = [
312+
focal_point[i] + (position[i] - focal_point[i]) / zoom
313+
for i in range(3)
314+
]
315+
properties["viewAngle"] = 45.0
316+
properties["parallelProjection"] = False
317+
properties.pop("parallelScale", None)
318+
319+
for child in node.get("dependencies", []):
320+
adjust_node(child)
321+
322+
adjust_node(data.get("scene"))
323+
entries["index.json"] = json.dumps(data).encode("utf-8")
324+
325+
with zipfile.ZipFile(filename, "w", compression=zipfile.ZIP_DEFLATED) as zf:
326+
for name, content in entries.items():
327+
zf.writestr(name, content)
328+
228329

229330
def ascii_storage_files(self):
230331
"""
@@ -389,6 +490,9 @@ def show(self, block: bool = True):
389490
except AttributeError:
390491
pass # BackgroundPlotter may not expose ren_win directly
391492

493+
if self.pyvista_vtksz and not self._pyvista_vtksz_exported:
494+
self.export_vtksz(self.pyvista_vtksz)
495+
392496
if self.use_background_plotter:
393497
# BackgroundPlotter path
394498
if block and hasattr(p, "app"):
@@ -408,7 +512,7 @@ def show(self, block: bool = True):
408512
)
409513

410514

411-
elif block:
515+
elif block and self.show_pyvista_window:
412516
p.show()
413517

414518
def _configure_camera_from_bounds(self, margin: float = 0.8, distance_scale: float = 4.0):
@@ -496,6 +600,7 @@ def autogeometry(
496600
auto_show: bool = True,
497601
enable_pyvista: Optional[bool] = None,
498602
use_background_plotter: Optional[bool] = None,
603+
pyvista_vtksz: Optional[str] = None,
499604
):
500605
# in jupyter: always enable pyvista, never use atexit
501606
if _in_jupyter:
@@ -507,6 +612,7 @@ def autogeometry(
507612
application,
508613
enable_pyvista=enable_pyvista,
509614
use_background_plotter=use_background_plotter,
615+
pyvista_vtksz=pyvista_vtksz,
510616
)
511617

512618
if auto_show:

0 commit comments

Comments
 (0)