geant4-python-application 0.1.0__cp313-cp313-macosx_11_0_arm64.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.
- geant4_python_application/.dylibs/libG4FR.dylib +0 -0
- geant4_python_application/.dylibs/libG4GMocren.dylib +0 -0
- geant4_python_application/.dylibs/libG4OpenGL.dylib +0 -0
- geant4_python_application/.dylibs/libG4RayTracer.dylib +0 -0
- geant4_python_application/.dylibs/libG4ToolsSG.dylib +0 -0
- geant4_python_application/.dylibs/libG4Tree.dylib +0 -0
- geant4_python_application/.dylibs/libG4VRML.dylib +0 -0
- geant4_python_application/.dylibs/libG4analysis.dylib +0 -0
- geant4_python_application/.dylibs/libG4clhep.dylib +0 -0
- geant4_python_application/.dylibs/libG4digits_hits.dylib +0 -0
- geant4_python_application/.dylibs/libG4error_propagation.dylib +0 -0
- geant4_python_application/.dylibs/libG4event.dylib +0 -0
- geant4_python_application/.dylibs/libG4expat.dylib +0 -0
- geant4_python_application/.dylibs/libG4gdml.dylib +0 -0
- geant4_python_application/.dylibs/libG4geometry.dylib +0 -0
- geant4_python_application/.dylibs/libG4geomtext.dylib +0 -0
- geant4_python_application/.dylibs/libG4global.dylib +0 -0
- geant4_python_application/.dylibs/libG4graphics_reps.dylib +0 -0
- geant4_python_application/.dylibs/libG4intercoms.dylib +0 -0
- geant4_python_application/.dylibs/libG4interfaces.dylib +0 -0
- geant4_python_application/.dylibs/libG4materials.dylib +0 -0
- geant4_python_application/.dylibs/libG4mctruth.dylib +0 -0
- geant4_python_application/.dylibs/libG4modeling.dylib +0 -0
- geant4_python_application/.dylibs/libG4parmodels.dylib +0 -0
- geant4_python_application/.dylibs/libG4particles.dylib +0 -0
- geant4_python_application/.dylibs/libG4physicslists.dylib +0 -0
- geant4_python_application/.dylibs/libG4processes.dylib +0 -0
- geant4_python_application/.dylibs/libG4ptl.2.3.3.dylib +0 -0
- geant4_python_application/.dylibs/libG4readout.dylib +0 -0
- geant4_python_application/.dylibs/libG4run.dylib +0 -0
- geant4_python_application/.dylibs/libG4track.dylib +0 -0
- geant4_python_application/.dylibs/libG4tracking.dylib +0 -0
- geant4_python_application/.dylibs/libG4visHepRep.dylib +0 -0
- geant4_python_application/.dylibs/libG4vis_management.dylib +0 -0
- geant4_python_application/__init__.py +49 -0
- geant4_python_application/_geant4_application.cpython-313-darwin.so +0 -0
- geant4_python_application/_geant4_vis.cpython-313-darwin.so +0 -0
- geant4_python_application/_qt_from_pyqt5_qt5 +1 -0
- geant4_python_application/_version.py +24 -0
- geant4_python_application/application.py +442 -0
- geant4_python_application/cad.py +124 -0
- geant4_python_application/detector.py +97 -0
- geant4_python_application/distributed.py +45 -0
- geant4_python_application/events.py +87 -0
- geant4_python_application/files/__init__.py +1 -0
- geant4_python_application/files/datasets.py +297 -0
- geant4_python_application/files/directories.py +40 -0
- geant4_python_application/gdml.py +39 -0
- geant4_python_application/generator.py +63 -0
- geant4_python_application/geometry.py +52 -0
- geant4_python_application/io.py +154 -0
- geant4_python_application/optical.py +199 -0
- geant4_python_application/scoring.py +118 -0
- geant4_python_application-0.1.0.dist-info/METADATA +220 -0
- geant4_python_application-0.1.0.dist-info/RECORD +66 -0
- geant4_python_application-0.1.0.dist-info/WHEEL +5 -0
- geant4_python_application-0.1.0.dist-info/licenses/LICENSE +28 -0
- geant4_python_application-0.1.0.dist-info/licenses/NOTICE +21 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/CLHEP-COPYING +674 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/CLHEP-COPYING.LESSER +165 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/Expat-COPYING +22 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/Geant4-LICENSE +73 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/PTL-LICENSE +21 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/Xerces-C-LICENSE +202 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/Xerces-C-NOTICE +10 -0
- geant4_python_application-0.1.0.dist-info/licenses/licenses/zlib-LICENSE +22 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""CAD -> GDML helpers via tessellated solids (no external CAD kernel).
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- mesh_to_gdml(vertices, faces): build GDML with a <tessellated> solid.
|
|
7
|
+
- stl_ascii_to_gdml(stl_text): parse ASCII STL -> GDML (triangles only).
|
|
8
|
+
Both produce a self-contained GDML string with world box + placed volume,
|
|
9
|
+
suitable for Application(gdml=...).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def mesh_to_gdml(
|
|
14
|
+
vertices: list[tuple[float, float, float]],
|
|
15
|
+
faces: list[tuple[int, int, int]],
|
|
16
|
+
*,
|
|
17
|
+
name: str = "cad_solid",
|
|
18
|
+
material: str = "G4_WATER",
|
|
19
|
+
world_size_mm: float = 1000.0,
|
|
20
|
+
unit: str = "mm",
|
|
21
|
+
) -> str:
|
|
22
|
+
if len(vertices) == 0 or len(faces) == 0:
|
|
23
|
+
msg = "vertices and faces must be non-empty"
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
pos_lines = []
|
|
26
|
+
for i, (x, y, z) in enumerate(vertices):
|
|
27
|
+
pos_lines.append(
|
|
28
|
+
f' <position name="v{i}" unit="{unit}" x="{x}" y="{y}" z="{z}" />'
|
|
29
|
+
)
|
|
30
|
+
tri_lines = []
|
|
31
|
+
for a, b, c in faces:
|
|
32
|
+
if min(a, b, c) < 0 or max(a, b, c) >= len(vertices):
|
|
33
|
+
msg = f"face index out of range: {(a, b, c)}"
|
|
34
|
+
raise ValueError(msg)
|
|
35
|
+
tri_lines.append(
|
|
36
|
+
f' <triangular vertex1="v{a}" vertex2="v{b}" vertex3="v{c}" />'
|
|
37
|
+
)
|
|
38
|
+
positions = "\n".join(pos_lines)
|
|
39
|
+
triangles = "\n".join(tri_lines)
|
|
40
|
+
return f"""<?xml version="1.0" encoding="UTF-8" ?>
|
|
41
|
+
<gdml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://service-spi.web.cern.ch/service-spi/app/releases/GDML/schema/gdml.xsd">
|
|
42
|
+
<define>
|
|
43
|
+
{positions}
|
|
44
|
+
<position name="cadpos" unit="{unit}" x="0" y="0" z="0" />
|
|
45
|
+
</define>
|
|
46
|
+
<materials>
|
|
47
|
+
<element Z="1" formula="H" name="H"><atom value="1.0"/></element>
|
|
48
|
+
</materials>
|
|
49
|
+
<solids>
|
|
50
|
+
<box lunit="{unit}" name="world" x="{world_size_mm}" y="{world_size_mm}" z="{world_size_mm}" />
|
|
51
|
+
<tessellated aunit="degree" lunit="{unit}" name="{name}">
|
|
52
|
+
{triangles}
|
|
53
|
+
</tessellated>
|
|
54
|
+
</solids>
|
|
55
|
+
<structure>
|
|
56
|
+
<volume name="CadVolume">
|
|
57
|
+
<materialref ref="{material}"/>
|
|
58
|
+
<solidref ref="{name}"/>
|
|
59
|
+
</volume>
|
|
60
|
+
<volume name="World">
|
|
61
|
+
<materialref ref="G4_AIR"/>
|
|
62
|
+
<solidref ref="world"/>
|
|
63
|
+
<physvol name="cad">
|
|
64
|
+
<volumeref ref="CadVolume"/>
|
|
65
|
+
<positionref ref="cadpos"/>
|
|
66
|
+
</physvol>
|
|
67
|
+
</volume>
|
|
68
|
+
</structure>
|
|
69
|
+
<setup name="Default" version="1.0"><world ref="World"/></setup>
|
|
70
|
+
</gdml>
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cube_mesh(size_mm: float = 100.0) -> tuple[list, list]:
|
|
75
|
+
"""Return (vertices, faces) for an axis-aligned cube centred at origin."""
|
|
76
|
+
h = size_mm / 2.0
|
|
77
|
+
v = [
|
|
78
|
+
(-h, -h, -h),
|
|
79
|
+
(h, -h, -h),
|
|
80
|
+
(h, h, -h),
|
|
81
|
+
(-h, h, -h),
|
|
82
|
+
(-h, -h, h),
|
|
83
|
+
(h, -h, h),
|
|
84
|
+
(h, h, h),
|
|
85
|
+
(-h, h, h),
|
|
86
|
+
]
|
|
87
|
+
f = [
|
|
88
|
+
(0, 1, 2),
|
|
89
|
+
(0, 2, 3), # -z
|
|
90
|
+
(4, 6, 5),
|
|
91
|
+
(4, 7, 6), # +z
|
|
92
|
+
(0, 4, 5),
|
|
93
|
+
(0, 5, 1), # -y
|
|
94
|
+
(3, 2, 6),
|
|
95
|
+
(3, 6, 7), # +y
|
|
96
|
+
(0, 3, 7),
|
|
97
|
+
(0, 7, 4), # -x
|
|
98
|
+
(1, 5, 6),
|
|
99
|
+
(1, 6, 2), # +x
|
|
100
|
+
]
|
|
101
|
+
return v, f
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stl_ascii_to_gdml(stl_text: str, **kwargs) -> str:
|
|
105
|
+
"""Parse minimal ASCII STL (facet normal/vertex lines) into GDML."""
|
|
106
|
+
verts: list[tuple[float, float, float]] = []
|
|
107
|
+
index: dict[tuple[float, float, float], int] = {}
|
|
108
|
+
faces: list[tuple[int, int, int]] = []
|
|
109
|
+
cur: list[int] = []
|
|
110
|
+
for line in stl_text.splitlines():
|
|
111
|
+
s = line.strip().split()
|
|
112
|
+
if len(s) >= 4 and s[0] == "vertex":
|
|
113
|
+
p = (float(s[1]), float(s[2]), float(s[3]))
|
|
114
|
+
if p not in index:
|
|
115
|
+
index[p] = len(verts)
|
|
116
|
+
verts.append(p)
|
|
117
|
+
cur.append(index[p])
|
|
118
|
+
if len(cur) == 3:
|
|
119
|
+
faces.append((cur[0], cur[1], cur[2]))
|
|
120
|
+
cur = []
|
|
121
|
+
if not faces:
|
|
122
|
+
msg = "no facets parsed from STL text"
|
|
123
|
+
raise ValueError(msg)
|
|
124
|
+
return mesh_to_gdml(verts, faces, **kwargs)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from geant4_python_application import Application
|
|
4
|
+
from geant4_python_application.application import Message
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Detector:
|
|
8
|
+
def __init__(self, application: Application):
|
|
9
|
+
self._application = application
|
|
10
|
+
|
|
11
|
+
def check_overlaps(self) -> Detector:
|
|
12
|
+
return self._application._send_and_recv(
|
|
13
|
+
Message("detector", "check_overlaps", (), {})
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def gdml(self) -> str:
|
|
18
|
+
return self._application._send_and_recv(Message("detector", "get_gdml", (), {}))
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def logical_volumes(self):
|
|
22
|
+
return self._application._send_and_recv(
|
|
23
|
+
Message("detector", "get_logical_volumes", (), {})
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def physical_volumes(self):
|
|
28
|
+
return self._application._send_and_recv(
|
|
29
|
+
Message("detector", "get_physical_volumes", (), {})
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def materials(self):
|
|
34
|
+
return self._application._send_and_recv(
|
|
35
|
+
Message("detector", "get_materials", (), {})
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def sensitive_volumes(self):
|
|
40
|
+
return self._application._send_and_recv(
|
|
41
|
+
Message("detector", "get_sensitive_volumes", (), {})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@sensitive_volumes.setter
|
|
45
|
+
def sensitive_volumes(self, volumes):
|
|
46
|
+
self._application._send_and_recv(
|
|
47
|
+
Message("detector", "set_sensitive_volumes", (volumes,), {})
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def magnetic_field(self) -> tuple[float, float, float]:
|
|
52
|
+
"""Uniform global magnetic field vector in tesla."""
|
|
53
|
+
return tuple(
|
|
54
|
+
self._application._send_and_recv(
|
|
55
|
+
Message("detector", "get_magnetic_field", (), {})
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@magnetic_field.setter
|
|
60
|
+
def magnetic_field(self, field_tesla: tuple[float, float, float]):
|
|
61
|
+
self._application._send_and_recv(
|
|
62
|
+
Message("detector", "set_magnetic_field", (field_tesla,), {})
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def electric_field(self) -> tuple[float, float, float]:
|
|
67
|
+
"""Uniform global electric field vector in kV/cm."""
|
|
68
|
+
return tuple(
|
|
69
|
+
self._application._send_and_recv(
|
|
70
|
+
Message("detector", "get_electric_field", (), {})
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@electric_field.setter
|
|
75
|
+
def electric_field(self, field_kv_per_cm: tuple[float, float, float]):
|
|
76
|
+
self._application._send_and_recv(
|
|
77
|
+
Message("detector", "set_electric_field", (field_kv_per_cm,), {})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def material_from_volume(self, volume: str) -> str:
|
|
81
|
+
return self._application._send_and_recv(
|
|
82
|
+
Message("detector", "get_material_from_volume", (volume,), {})
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def logical_volume_from_physical(self, physical: str) -> str:
|
|
86
|
+
return self._application._send_and_recv(
|
|
87
|
+
Message(
|
|
88
|
+
"detector", "get_logical_volume_from_physical_volume", (physical,), {}
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def physical_volumes_from_logical(self, logical: str) -> set[str]:
|
|
93
|
+
return self._application._send_and_recv(
|
|
94
|
+
Message(
|
|
95
|
+
"detector", "get_physical_volumes_from_logical_volume", (logical,), {}
|
|
96
|
+
)
|
|
97
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""MPI / distributed helpers with graceful fallback (no hard mpi4py dep)."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_rank_size() -> tuple[int, int]:
|
|
9
|
+
"""Return (rank, size) from mpi4py if available, else env vars, else (0,1)."""
|
|
10
|
+
try:
|
|
11
|
+
from mpi4py import MPI # type: ignore
|
|
12
|
+
|
|
13
|
+
comm = MPI.COMM_WORLD
|
|
14
|
+
return int(comm.Get_rank()), int(comm.Get_size())
|
|
15
|
+
except Exception:
|
|
16
|
+
pass
|
|
17
|
+
for rank_var, size_var in [
|
|
18
|
+
("OMPI_COMM_WORLD_RANK", "OMPI_COMM_WORLD_SIZE"),
|
|
19
|
+
("PMI_RANK", "PMI_SIZE"),
|
|
20
|
+
("MPI_RANK", "MPI_SIZE"),
|
|
21
|
+
]:
|
|
22
|
+
if rank_var in os.environ and size_var in os.environ:
|
|
23
|
+
try:
|
|
24
|
+
return int(os.environ[rank_var]), int(os.environ[size_var])
|
|
25
|
+
except ValueError:
|
|
26
|
+
continue
|
|
27
|
+
return 0, 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def split_count(n_total: int, rank: int, size: int) -> tuple[int, int]:
|
|
31
|
+
"""Return (start, count) for rank under even split of n_total events."""
|
|
32
|
+
if n_total < 0 or rank < 0 or size <= 0 or rank >= size:
|
|
33
|
+
msg = f"invalid split args: n={n_total} rank={rank} size={size}"
|
|
34
|
+
raise ValueError(msg)
|
|
35
|
+
base, rem = divmod(n_total, size)
|
|
36
|
+
start = rank * base + min(rank, rem)
|
|
37
|
+
count = base + (1 if rank < rem else 0)
|
|
38
|
+
return start, count
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def local_count(n_total: int) -> tuple[int, int, int]:
|
|
42
|
+
"""Return (rank, size, count) for this process."""
|
|
43
|
+
rank, size = get_rank_size()
|
|
44
|
+
_, count = split_count(n_total, rank, size)
|
|
45
|
+
return rank, size, count
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import awkward as ak
|
|
4
|
+
import numpy as np
|
|
5
|
+
import vector
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EventRecord(ak.Record):
|
|
9
|
+
def energy_in_volume(self, volume: str) -> float:
|
|
10
|
+
energy = self.track.step.energy[self.track.step.volume == volume]
|
|
11
|
+
# collapse to a scalar
|
|
12
|
+
return ak.sum(energy)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventArray(ak.Array):
|
|
16
|
+
def energy_in_volume(self, volume: str) -> ak.Array:
|
|
17
|
+
energy = self.track.step.energy[self.track.step.volume == volume]
|
|
18
|
+
# collapse to an array of scalars (one per event)
|
|
19
|
+
return ak.sum(ak.flatten(energy, axis=-1), axis=1)
|
|
20
|
+
|
|
21
|
+
def hits(self, volume: str | None = None) -> ak.Array:
|
|
22
|
+
hits = ak.flatten(self.track.step, axis=-1)
|
|
23
|
+
hits = hits[hits.energy > 0]
|
|
24
|
+
if volume is not None:
|
|
25
|
+
hits = hits[hits.volume == volume]
|
|
26
|
+
# Keep available hit fields; event-field selection may omit time or position.
|
|
27
|
+
fields = [
|
|
28
|
+
field for field in ("position", "time", "energy") if field in hits.fields
|
|
29
|
+
]
|
|
30
|
+
hits = hits[fields]
|
|
31
|
+
return ak.Array(hits, with_name="Geant4Hits")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HitsRecord(ak.Record):
|
|
35
|
+
# This is really expensive!
|
|
36
|
+
def electrons(self, work_function: float = 20.0 / 1e3) -> ak.Array:
|
|
37
|
+
n_electrons = np.round(self.energy / work_function)
|
|
38
|
+
return ak.flatten(
|
|
39
|
+
ak.Array(
|
|
40
|
+
[
|
|
41
|
+
[
|
|
42
|
+
{
|
|
43
|
+
"position": self.position[i],
|
|
44
|
+
"time": self.time[i],
|
|
45
|
+
}
|
|
46
|
+
for _ in range(int(n_electrons[i]))
|
|
47
|
+
]
|
|
48
|
+
for i in range(len(self.energy))
|
|
49
|
+
],
|
|
50
|
+
with_name="Geant4Electrons",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ElectronsRecord(ak.Array):
|
|
56
|
+
def drift(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
drift_velocity: float,
|
|
60
|
+
longitudinal_diffusion: float,
|
|
61
|
+
transversal_diffusion: float,
|
|
62
|
+
plane_point: tuple[float, float, float],
|
|
63
|
+
plane_normal: tuple[float, float, float],
|
|
64
|
+
) -> ElectronsRecord:
|
|
65
|
+
# TODO: not working yet
|
|
66
|
+
plane_normal = vector.obj(
|
|
67
|
+
x=plane_normal[0], y=plane_normal[1], z=plane_normal[2]
|
|
68
|
+
).unit()
|
|
69
|
+
assert np.isclose(plane_normal.mag, 1.0)
|
|
70
|
+
plane_point = vector.obj(x=plane_point[0], y=plane_point[1], z=plane_point[2])
|
|
71
|
+
distance_to_plane = (self.position - plane_point) * plane_normal
|
|
72
|
+
self.time += distance_to_plane / drift_velocity + np.random.normal(
|
|
73
|
+
0, longitudinal_diffusion * distance_to_plane, len(self.time)
|
|
74
|
+
)
|
|
75
|
+
self.position += -distance_to_plane * plane_normal
|
|
76
|
+
self.position += np.random.normal(
|
|
77
|
+
0, transversal_diffusion * distance_to_plane, len(self.position)
|
|
78
|
+
)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
ak.behavior["Geant4Event"] = EventRecord
|
|
83
|
+
ak.behavior["*", "Geant4Event"] = EventArray
|
|
84
|
+
|
|
85
|
+
ak.behavior["Geant4Hits"] = HitsRecord
|
|
86
|
+
|
|
87
|
+
ak.behavior["Geant4Electrons"] = ElectronsRecord
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from collections import namedtuple
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from tqdm import tqdm
|
|
16
|
+
|
|
17
|
+
import geant4_python_application
|
|
18
|
+
|
|
19
|
+
url = "https://cern.ch/geant4-data/datasets"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# It is discouraged to use the python package directory to store data
|
|
23
|
+
# data_dir = os.path.join(os.path.dirname(__file__), "geant4/data")
|
|
24
|
+
# another idea is to use 'platformdirs' to store data in a platform-specific location
|
|
25
|
+
def application_data_directory() -> str:
|
|
26
|
+
return os.path.join(
|
|
27
|
+
geant4_python_application.application_directory(),
|
|
28
|
+
geant4_python_application.__name__,
|
|
29
|
+
"geant4",
|
|
30
|
+
geant4_python_application.geant4_version,
|
|
31
|
+
"data",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def geant4_data_directory() -> str:
|
|
36
|
+
try:
|
|
37
|
+
# capture stdout, save into a str
|
|
38
|
+
geant4_prefix = subprocess.run(
|
|
39
|
+
["geant4-config", "--prefix"], capture_output=True, check=False
|
|
40
|
+
)
|
|
41
|
+
geant4_data_path = os.path.join(
|
|
42
|
+
geant4_prefix.stdout.decode().strip(), "share", "Geant4", "data"
|
|
43
|
+
)
|
|
44
|
+
if not os.path.exists(geant4_data_path):
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
return geant4_data_path
|
|
48
|
+
|
|
49
|
+
except FileNotFoundError:
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# the datasets versions should be updated with each Geant4 version (remember to update the checksum too!)
|
|
54
|
+
# https://geant4.web.cern.ch/download/11.2.2.html#datasets
|
|
55
|
+
Dataset = namedtuple("Dataset", ["name", "version", "filename", "env", "md5sum"])
|
|
56
|
+
|
|
57
|
+
datasets = (
|
|
58
|
+
Dataset(
|
|
59
|
+
name="G4NDL",
|
|
60
|
+
version="4.7.1",
|
|
61
|
+
filename="G4NDL",
|
|
62
|
+
env="G4NEUTRONHPDATA",
|
|
63
|
+
md5sum="54f0ed3995856f02433d42ec96d70bc6",
|
|
64
|
+
),
|
|
65
|
+
Dataset(
|
|
66
|
+
name="G4EMLOW",
|
|
67
|
+
version="8.5",
|
|
68
|
+
filename="G4EMLOW",
|
|
69
|
+
env="G4LEDATA",
|
|
70
|
+
md5sum="146d0625d8d39f294056e1618271bc46",
|
|
71
|
+
),
|
|
72
|
+
Dataset(
|
|
73
|
+
name="PhotonEvaporation",
|
|
74
|
+
version="5.7",
|
|
75
|
+
filename="G4PhotonEvaporation",
|
|
76
|
+
env="G4LEVELGAMMADATA",
|
|
77
|
+
md5sum="81ff27deb23af4aa225423e6b3a06b39",
|
|
78
|
+
),
|
|
79
|
+
Dataset(
|
|
80
|
+
name="RadioactiveDecay",
|
|
81
|
+
version="5.6",
|
|
82
|
+
filename="G4RadioactiveDecay",
|
|
83
|
+
env="G4RADIOACTIVEDATA",
|
|
84
|
+
md5sum="acc1dbeb87b6b708b2874ced729a3a8f",
|
|
85
|
+
),
|
|
86
|
+
Dataset(
|
|
87
|
+
name="G4PARTICLEXS",
|
|
88
|
+
version="4.0",
|
|
89
|
+
filename="G4PARTICLEXS",
|
|
90
|
+
env="G4PARTICLEXSDATA",
|
|
91
|
+
md5sum="d82a4d171d50f55864e28b6cd6f433c0",
|
|
92
|
+
),
|
|
93
|
+
Dataset(
|
|
94
|
+
name="G4PII",
|
|
95
|
+
version="1.3",
|
|
96
|
+
filename="G4PII",
|
|
97
|
+
env="G4PIIDATA",
|
|
98
|
+
md5sum="05f2471dbcdf1a2b17cbff84e8e83b37",
|
|
99
|
+
),
|
|
100
|
+
Dataset(
|
|
101
|
+
name="RealSurface",
|
|
102
|
+
version="2.2",
|
|
103
|
+
filename="G4RealSurface",
|
|
104
|
+
env="G4REALSURFACEDATA",
|
|
105
|
+
md5sum="ea8f1cfa8d8aafd64b71fb30b3e8a6d9",
|
|
106
|
+
),
|
|
107
|
+
Dataset(
|
|
108
|
+
name="G4SAIDDATA",
|
|
109
|
+
version="2.0",
|
|
110
|
+
filename="G4SAIDDATA",
|
|
111
|
+
env="G4SAIDXSDATA",
|
|
112
|
+
md5sum="d5d4e9541120c274aeed038c621d39da",
|
|
113
|
+
),
|
|
114
|
+
Dataset(
|
|
115
|
+
name="G4ABLA",
|
|
116
|
+
version="3.3",
|
|
117
|
+
filename="G4ABLA",
|
|
118
|
+
env="G4ABLADATA",
|
|
119
|
+
md5sum="b25d093339e1e4532e31038653580ca6",
|
|
120
|
+
),
|
|
121
|
+
Dataset(
|
|
122
|
+
name="G4INCL",
|
|
123
|
+
version="1.2",
|
|
124
|
+
filename="G4INCL",
|
|
125
|
+
env="G4INCLDATA",
|
|
126
|
+
md5sum="0a76df936839bb557dae7254117eb58e",
|
|
127
|
+
),
|
|
128
|
+
Dataset(
|
|
129
|
+
name="G4ENSDFSTATE",
|
|
130
|
+
version="2.3",
|
|
131
|
+
filename="G4ENSDFSTATE",
|
|
132
|
+
env="G4ENSDFSTATEDATA",
|
|
133
|
+
md5sum="6f18fce8f217e7aaeaa3711be9b2c7bf",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _dataset_url(dataset: Dataset) -> str:
|
|
139
|
+
return f"{url}/{dataset.filename}.{dataset.version}.tar.gz"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _get_dataset_download_size(dataset: Dataset) -> int:
|
|
143
|
+
r = requests.head(_dataset_url(dataset))
|
|
144
|
+
r.raise_for_status()
|
|
145
|
+
return int(r.headers.get("content-length", 0))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_total_download_size(datasets_to_download: list[Dataset] = datasets) -> int:
|
|
149
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
150
|
+
max_workers=len(datasets_to_download)
|
|
151
|
+
) as executor:
|
|
152
|
+
futures = [
|
|
153
|
+
executor.submit(_get_dataset_download_size, dataset)
|
|
154
|
+
for dataset in datasets_to_download
|
|
155
|
+
]
|
|
156
|
+
return sum(f.result() for f in concurrent.futures.as_completed(futures))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
chunk_size = 1024 * 1024
|
|
160
|
+
max_attempts = 5
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _download_extract_dataset(dataset: Dataset, pbar: tqdm):
|
|
164
|
+
filename = dataset.filename
|
|
165
|
+
urlpath = _dataset_url(dataset)
|
|
166
|
+
|
|
167
|
+
with tempfile.TemporaryFile() as f:
|
|
168
|
+
downloaded = 0
|
|
169
|
+
for attempt in range(max_attempts):
|
|
170
|
+
# resume where the previous attempt died instead of restarting a multi-GB download
|
|
171
|
+
headers = {"Range": f"bytes={downloaded}-"} if downloaded else {}
|
|
172
|
+
try:
|
|
173
|
+
r = requests.get(urlpath, stream=True, headers=headers, timeout=60)
|
|
174
|
+
r.raise_for_status()
|
|
175
|
+
if downloaded and r.status_code != 206:
|
|
176
|
+
# server ignored the range request, so start over
|
|
177
|
+
f.seek(0)
|
|
178
|
+
f.truncate()
|
|
179
|
+
pbar.update(-downloaded)
|
|
180
|
+
downloaded = 0
|
|
181
|
+
for chunk in r.iter_content(chunk_size=chunk_size):
|
|
182
|
+
f.write(chunk)
|
|
183
|
+
downloaded += len(chunk)
|
|
184
|
+
pbar.update(len(chunk))
|
|
185
|
+
break
|
|
186
|
+
except requests.RequestException:
|
|
187
|
+
if attempt == max_attempts - 1:
|
|
188
|
+
raise
|
|
189
|
+
time.sleep(2**attempt)
|
|
190
|
+
|
|
191
|
+
f.seek(0)
|
|
192
|
+
md5 = hashlib.md5()
|
|
193
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
194
|
+
md5.update(chunk)
|
|
195
|
+
if md5.hexdigest() != dataset.md5sum:
|
|
196
|
+
print(
|
|
197
|
+
f"MD5 checksum mismatch for {filename}: got {md5.hexdigest()} but expected {dataset.md5sum}"
|
|
198
|
+
)
|
|
199
|
+
raise RuntimeError(f"MD5 checksum mismatch for {filename}")
|
|
200
|
+
|
|
201
|
+
f.seek(0)
|
|
202
|
+
with tarfile.open(fileobj=f, mode="r:gz") as tar:
|
|
203
|
+
tar.extractall(application_data_directory())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def missing_datasets(directory: str) -> list[Dataset]:
|
|
207
|
+
datasets_to_download = []
|
|
208
|
+
for dataset in datasets:
|
|
209
|
+
path = os.path.join(directory, dataset.name + dataset.version)
|
|
210
|
+
if not os.path.exists(path):
|
|
211
|
+
datasets_to_download.append(dataset)
|
|
212
|
+
return datasets_to_download
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_datasets(throw: bool = False) -> bool:
|
|
216
|
+
datasets_to_download = missing_datasets(directory=application_data_directory())
|
|
217
|
+
if datasets_to_download:
|
|
218
|
+
# if not found in the application data directory, try to find them in the Geant4 installation data directory
|
|
219
|
+
if geant4_data_directory():
|
|
220
|
+
datasets_to_download = missing_datasets(directory=geant4_data_directory())
|
|
221
|
+
|
|
222
|
+
if datasets_to_download:
|
|
223
|
+
if throw:
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"Missing Geant4 datasets: {', '.join([f'{dataset.name}@v{dataset.version}' for dataset in datasets_to_download])}"
|
|
226
|
+
)
|
|
227
|
+
return False
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def install_datasets(show_progress: bool = True):
|
|
232
|
+
# first try to see if the datasets are installed in the application directory
|
|
233
|
+
datasets_to_download = missing_datasets(directory=application_data_directory())
|
|
234
|
+
if not datasets_to_download:
|
|
235
|
+
# datasets are installed in application directory
|
|
236
|
+
os.environ["GEANT4_DATA_DIR"] = application_data_directory()
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
if geant4_data_directory():
|
|
240
|
+
datasets_to_download = missing_datasets(directory=geant4_data_directory())
|
|
241
|
+
if not datasets_to_download:
|
|
242
|
+
# datasets are installed in Geant4 data directory, no need to install them in the application data directory
|
|
243
|
+
os.environ["GEANT4_DATA_DIR"] = geant4_data_directory()
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# download and extract the datasets to the application data directory
|
|
247
|
+
os.environ["GEANT4_DATA_DIR"] = application_data_directory()
|
|
248
|
+
|
|
249
|
+
os.makedirs(application_data_directory(), exist_ok=True)
|
|
250
|
+
if show_progress:
|
|
251
|
+
print(
|
|
252
|
+
f"""
|
|
253
|
+
Geant4 datasets will be installed to "{application_data_directory()}".
|
|
254
|
+
This may take a while but only needs to be done once.
|
|
255
|
+
You can override the default location by calling `application_directory(path)` or `application_directory(temp=True)` to use a temporary directory.
|
|
256
|
+
The following Geant4 datasets will be installed: {", ".join([f"{dataset.name}@v{dataset.version}" for dataset in datasets_to_download])}"""
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
with tqdm(
|
|
260
|
+
total=_get_total_download_size(datasets_to_download),
|
|
261
|
+
desc="Downloading Geant4 datasets",
|
|
262
|
+
disable=not show_progress,
|
|
263
|
+
unit="B",
|
|
264
|
+
unit_scale=True,
|
|
265
|
+
) as pbar:
|
|
266
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
267
|
+
max_workers=len(datasets_to_download)
|
|
268
|
+
) as executor:
|
|
269
|
+
futures = [
|
|
270
|
+
executor.submit(_download_extract_dataset, dataset, pbar)
|
|
271
|
+
for dataset in datasets_to_download
|
|
272
|
+
]
|
|
273
|
+
for future in concurrent.futures.as_completed(futures):
|
|
274
|
+
# surface download/extraction errors instead of reporting a partial install as success
|
|
275
|
+
future.result()
|
|
276
|
+
|
|
277
|
+
if show_progress:
|
|
278
|
+
total_size_gb = sum(
|
|
279
|
+
fp.stat().st_size for fp in Path(application_data_directory()).rglob("*")
|
|
280
|
+
) / (1024**3)
|
|
281
|
+
print(f"Geant4 datasets size on disk after extraction: {total_size_gb:.2f}GB")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def uninstall_datasets():
|
|
285
|
+
dir_to_remove = os.path.dirname(application_data_directory())
|
|
286
|
+
package_dir = os.path.dirname(__file__)
|
|
287
|
+
|
|
288
|
+
if not os.path.relpath(package_dir, dir_to_remove).startswith(".."):
|
|
289
|
+
raise RuntimeError(
|
|
290
|
+
f"Refusing to remove {dir_to_remove} because it is not a subdirectory of {package_dir}"
|
|
291
|
+
)
|
|
292
|
+
shutil.rmtree(dir_to_remove, ignore_errors=True)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def reinstall_datasets():
|
|
296
|
+
uninstall_datasets()
|
|
297
|
+
install_datasets()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
import platformdirs
|
|
8
|
+
|
|
9
|
+
import geant4_python_application
|
|
10
|
+
|
|
11
|
+
_app_name = geant4_python_application.__name__
|
|
12
|
+
# TODO: get from pyproject.toml
|
|
13
|
+
_app_author = "lobis"
|
|
14
|
+
|
|
15
|
+
_dirs = platformdirs.AppDirs(_app_name, _app_author)
|
|
16
|
+
|
|
17
|
+
# Shared by every environment of the user, so the ~2 GB of datasets download
|
|
18
|
+
# only once. GEANT4_PYTHON_APPLICATION_DIR overrides it, e.g. on clusters.
|
|
19
|
+
_application_directory = os.environ.get(
|
|
20
|
+
"GEANT4_PYTHON_APPLICATION_DIR", _dirs.user_data_dir
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def application_directory(path: str | None = None, *, temp: bool = False) -> str:
|
|
25
|
+
global _application_directory
|
|
26
|
+
|
|
27
|
+
if temp and path:
|
|
28
|
+
raise ValueError("Cannot set both temp and path options")
|
|
29
|
+
if temp:
|
|
30
|
+
_application_directory = tempfile.gettempdir()
|
|
31
|
+
return _application_directory
|
|
32
|
+
if path:
|
|
33
|
+
# override the default application directory
|
|
34
|
+
# make sure path exists, otherwise create it. Throw if it cannot be created
|
|
35
|
+
pathlib.Path(path).mkdir(parents=True, exist_ok=True)
|
|
36
|
+
if not os.path.isdir(path):
|
|
37
|
+
raise ValueError(f"Cannot create application directory: {path}")
|
|
38
|
+
_application_directory = path
|
|
39
|
+
|
|
40
|
+
return _application_directory
|