pyRadMC 0.2.0__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.
- pyradmc/__init__.py +308 -0
- pyradmc/adapters/__init__.py +9 -0
- pyradmc/adapters/ct.py +244 -0
- pyradmc/backends/__init__.py +1 -0
- pyradmc/backends/ref/__init__.py +5 -0
- pyradmc/backends/ref/engine.py +463 -0
- pyradmc/backends/results.py +115 -0
- pyradmc/backends/warp/__init__.py +1 -0
- pyradmc/backends/warp/engine.py +3451 -0
- pyradmc/backends/warp/kernels.py +2401 -0
- pyradmc/backends/warp/physics.py +211 -0
- pyradmc/backends/warp/presolve.py +742 -0
- pyradmc/data/__init__.py +1 -0
- pyradmc/data/analytic.py +321 -0
- pyradmc/data/berger_seltzer.py +240 -0
- pyradmc/data/goudsmit_saunderson.py +1122 -0
- pyradmc/data/handles.py +34 -0
- pyradmc/data/interface.py +437 -0
- pyradmc/data/materials.py +345 -0
- pyradmc/data/tables.py +362 -0
- pyradmc/data/tabulated/__init__.py +10 -0
- pyradmc/data/tabulated/build.py +211 -0
- pyradmc/data/tabulated/eedl.py +315 -0
- pyradmc/data/tabulated/endf.py +258 -0
- pyradmc/data/tabulated/epdl.py +234 -0
- pyradmc/data/tabulated/format.py +102 -0
- pyradmc/data/tabulated/model.py +48 -0
- pyradmc/data/tabulated/precompile.py +267 -0
- pyradmc/data/tabulated/source.py +241 -0
- pyradmc/geometry/__init__.py +1 -0
- pyradmc/geometry/collimation.py +1284 -0
- pyradmc/geometry/cylinder.py +118 -0
- pyradmc/geometry/fluence.py +147 -0
- pyradmc/geometry/grid.py +337 -0
- pyradmc/geometry/head.py +599 -0
- pyradmc/geometry/phasespace.py +769 -0
- pyradmc/geometry/source.py +1195 -0
- pyradmc/geometry/spectrum.py +310 -0
- pyradmc/physics/__init__.py +1 -0
- pyradmc/physics/brems.py +83 -0
- pyradmc/physics/channel.py +55 -0
- pyradmc/physics/compton.py +118 -0
- pyradmc/physics/direction.py +81 -0
- pyradmc/physics/gs.py +166 -0
- pyradmc/physics/moller.py +87 -0
- pyradmc/physics/msc.py +54 -0
- pyradmc/physics/path.py +35 -0
- pyradmc/physics/rayleigh.py +118 -0
- pyradmc/physics/roulette.py +34 -0
- pyradmc/progress.py +96 -0
- pyradmc/py.typed +0 -0
- pyradmc/rng/__init__.py +12 -0
- pyradmc/rng/host.py +48 -0
- pyradmc/rng/interface.py +64 -0
- pyradmc/rng/warp_shim.py +81 -0
- pyradmc/scoring/__init__.py +1 -0
- pyradmc/scoring/cylinder.py +513 -0
- pyradmc/scoring/dij.py +434 -0
- pyradmc/scoring/dose.py +207 -0
- pyradmc/scoring/dose_to_water.py +84 -0
- pyradmc/scoring/grid.py +219 -0
- pyradmc/study.py +150 -0
- pyradmc/transport/__init__.py +1 -0
- pyradmc/transport/electron.py +481 -0
- pyradmc/transport/history.py +150 -0
- pyradmc/transport/particles.py +102 -0
- pyradmc/transport/photon.py +348 -0
- pyradmc-0.2.0.dist-info/METADATA +162 -0
- pyradmc-0.2.0.dist-info/RECORD +72 -0
- pyradmc-0.2.0.dist-info/WHEEL +4 -0
- pyradmc-0.2.0.dist-info/licenses/LICENSE +201 -0
- pyradmc-0.2.0.dist-info/licenses/NOTICE +60 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""The reference engine: pure NumPy, one history at a time, never optimized.
|
|
2
|
+
|
|
3
|
+
This is the oracle every other backend is validated against (AGENTS.md section 2.2).
|
|
4
|
+
Clarity beats speed here, always: no vectorization over histories, no caching beyond
|
|
5
|
+
what correctness requires, asserts left on. If this file is ever the bottleneck, the
|
|
6
|
+
answer is a faster *backend*, not a faster oracle.
|
|
7
|
+
|
|
8
|
+
The engine composes the pieces — source, per-history RNG, the transport loop of
|
|
9
|
+
:mod:`pyradmc.transport.photon`, the batched scorer — and owns nothing physical
|
|
10
|
+
itself (launch and memory management only, AGENTS.md section 3).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from functools import partial
|
|
17
|
+
|
|
18
|
+
from pyradmc import (
|
|
19
|
+
DIJ_TRUNCATION_RELATIVE,
|
|
20
|
+
ECUT_MEV,
|
|
21
|
+
ELECTRON_MASS_MEV,
|
|
22
|
+
PCUT_MEV,
|
|
23
|
+
__version__,
|
|
24
|
+
)
|
|
25
|
+
from pyradmc.backends.results import RunProvenance, TransportResult
|
|
26
|
+
from pyradmc.data.interface import CrossSectionSource
|
|
27
|
+
from pyradmc.geometry.grid import VoxelGrid
|
|
28
|
+
from pyradmc.geometry.source import BeamletSource, Source
|
|
29
|
+
from pyradmc.progress import ProgressCallback, ProgressEmitter
|
|
30
|
+
from pyradmc.rng.interface import RNG
|
|
31
|
+
from pyradmc.scoring.dij import BatchedBeamletScorer, DijAssembler, DijResult
|
|
32
|
+
from pyradmc.scoring.dose import BatchedDoseScorer, ScoringGeometry
|
|
33
|
+
from pyradmc.scoring.dose_to_water import validate_scoring_mode, water_spr
|
|
34
|
+
from pyradmc.scoring.grid import ScoringGrid
|
|
35
|
+
from pyradmc.transport.electron import default_step_energy_fraction
|
|
36
|
+
from pyradmc.transport.history import transport_history
|
|
37
|
+
from pyradmc.transport.particles import (
|
|
38
|
+
ELECTRON,
|
|
39
|
+
PHOTON,
|
|
40
|
+
POSITRON,
|
|
41
|
+
DepositWeightFn,
|
|
42
|
+
unit_weight,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = ["ReferenceEngine", "TransportResult"]
|
|
46
|
+
|
|
47
|
+
# Maps a Primary's per-particle kind name to a transport particle constant. A
|
|
48
|
+
# phase-space source sets ``kind`` per record; beam sources leave it None and the
|
|
49
|
+
# run's ``primary_kind`` argument decides.
|
|
50
|
+
_KIND_TO_PARTICLE = {"photon": PHOTON, "electron": ELECTRON, "positron": POSITRON}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _deposit_weight_for(
|
|
54
|
+
scoring_mode: str, cross_sections: CrossSectionSource, ecut: float, transport_electrons: bool
|
|
55
|
+
) -> DepositWeightFn:
|
|
56
|
+
"""Validate the scoring mode and build the per-deposit weight it selects.
|
|
57
|
+
|
|
58
|
+
``scoring_mode`` is a scoring-OUTPUT selection, not a physics toggle
|
|
59
|
+
(AGENTS.md 2.10): transport is identical in both modes — the RNG streams,
|
|
60
|
+
interaction sampling and stepping never see it — only the tally weighting of
|
|
61
|
+
each deposit differs, exactly like the choice of scoring grid. Dose-to-water
|
|
62
|
+
is refused in KERMA mode: with no tracked electron the stopping-power ratio
|
|
63
|
+
has nothing to be evaluated on (see :mod:`pyradmc.scoring.dose_to_water`).
|
|
64
|
+
"""
|
|
65
|
+
if not validate_scoring_mode(scoring_mode, transport_electrons):
|
|
66
|
+
return unit_weight
|
|
67
|
+
return partial(water_spr, cross_sections=cross_sections, ecut=ecut)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ReferenceEngine:
|
|
72
|
+
"""Single-threaded reference photon engine over a voxel grid."""
|
|
73
|
+
|
|
74
|
+
grid: VoxelGrid
|
|
75
|
+
cross_sections: CrossSectionSource
|
|
76
|
+
rng: RNG
|
|
77
|
+
|
|
78
|
+
def _provenance(
|
|
79
|
+
self,
|
|
80
|
+
seed: int,
|
|
81
|
+
pcut: float,
|
|
82
|
+
ecut: float,
|
|
83
|
+
msc_model: str,
|
|
84
|
+
step_energy_fraction: float | None,
|
|
85
|
+
deposit_resolution_cm: float | None,
|
|
86
|
+
) -> RunProvenance:
|
|
87
|
+
"""Record the configuration this run actually used.
|
|
88
|
+
|
|
89
|
+
``step_energy_fraction`` is resolved here rather than stored as the caller's
|
|
90
|
+
``None``: the whole point of the record is to say what ran, and the default
|
|
91
|
+
follows ``msc_model``.
|
|
92
|
+
"""
|
|
93
|
+
return RunProvenance(
|
|
94
|
+
version=__version__,
|
|
95
|
+
backend="ref",
|
|
96
|
+
device="cpu",
|
|
97
|
+
seed=seed,
|
|
98
|
+
pcut_mev=pcut,
|
|
99
|
+
ecut_mev=ecut,
|
|
100
|
+
msc_model=msc_model,
|
|
101
|
+
step_energy_fraction=(
|
|
102
|
+
default_step_energy_fraction(msc_model)
|
|
103
|
+
if step_energy_fraction is None
|
|
104
|
+
else step_energy_fraction
|
|
105
|
+
),
|
|
106
|
+
cross_sections=self.cross_sections.provenance,
|
|
107
|
+
deposit_resolution_cm=deposit_resolution_cm,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def run(
|
|
111
|
+
self,
|
|
112
|
+
source: Source,
|
|
113
|
+
n_histories: int,
|
|
114
|
+
n_batches: int,
|
|
115
|
+
seed: int,
|
|
116
|
+
pcut: float = PCUT_MEV,
|
|
117
|
+
ecut: float = ECUT_MEV,
|
|
118
|
+
transport_electrons: bool = True,
|
|
119
|
+
primary_kind: str = "photon",
|
|
120
|
+
scoring_grid: ScoringGeometry | None = None,
|
|
121
|
+
scoring_mode: str = "dose_to_medium",
|
|
122
|
+
step_energy_fraction: float | None = None,
|
|
123
|
+
deposit_resolution_cm: float | None = None,
|
|
124
|
+
msc_model: str = "gs",
|
|
125
|
+
progress: ProgressCallback | None = None,
|
|
126
|
+
concurrent_batches: int = 1,
|
|
127
|
+
) -> TransportResult:
|
|
128
|
+
"""Transport ``n_histories`` primaries in ``n_batches`` equal batches.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
source
|
|
133
|
+
Primary source; its geometry is particle-agnostic (see ``primary_kind``).
|
|
134
|
+
n_histories
|
|
135
|
+
Total primaries; must be divisible by ``n_batches`` so every batch mean
|
|
136
|
+
carries equal statistical weight.
|
|
137
|
+
n_batches
|
|
138
|
+
Batches for the sigma estimate (AGENTS.md section 2.4).
|
|
139
|
+
seed
|
|
140
|
+
Global seed; history ``i`` uses the stream ``(seed, i)``, so the result
|
|
141
|
+
is bit-reproducible for a given target and seed regardless of batching.
|
|
142
|
+
pcut, ecut
|
|
143
|
+
Photon and electron cutoffs in MeV. Accuracy-defining (AGENTS.md
|
|
144
|
+
section 2.8); the defaults are the project-wide values and changing one
|
|
145
|
+
in a call is a visible, greppable decision.
|
|
146
|
+
transport_electrons
|
|
147
|
+
False selects the KERMA approximation (charged secondaries
|
|
148
|
+
deposit at their creation voxel) — the explicit option docs/decisions.md
|
|
149
|
+
keeps for photon-only physics tests.
|
|
150
|
+
primary_kind
|
|
151
|
+
``"photon"`` (default) or ``"electron"``: the fallback kind for sources
|
|
152
|
+
whose emitted :class:`~pyradmc.geometry.source.Primary` leaves ``kind``
|
|
153
|
+
unset (the monoenergetic beam sources). A phase-space source overrides
|
|
154
|
+
it per record, so this argument is ignored for that source. The electron
|
|
155
|
+
option exists for validating electron transport against ranges; electron
|
|
156
|
+
*beams* as a clinical modality remain out of scope (AGENTS.md 6).
|
|
157
|
+
scoring_grid
|
|
158
|
+
Scoring geometry to accumulate dose on (decoupled scoring). ``None``
|
|
159
|
+
(default) scores on the transport grid — byte-identical to the
|
|
160
|
+
engine before scoring grids existed. Build a coarser, offset or
|
|
161
|
+
subregion grid with :meth:`pyradmc.scoring.grid.ScoringGrid.rebin`, or
|
|
162
|
+
a depth-by-radial-shell pencil-beam kernel binning with
|
|
163
|
+
:meth:`pyradmc.scoring.cylinder.CylindricalScoringGrid.for_grid`,
|
|
164
|
+
**from the same transport grid handed to this engine**; deposits it
|
|
165
|
+
does not cover are booked to ``TransportResult.energy_unscored``, so
|
|
166
|
+
``emitted == deposited + unscored + escaped`` stays exact. Transport
|
|
167
|
+
never sees this geometry: the streams, and hence the physics, are
|
|
168
|
+
invariant to it — which is why a cylindrical binning is a readout
|
|
169
|
+
choice and not a physics flag (AGENTS.md 2.10).
|
|
170
|
+
scoring_mode
|
|
171
|
+
``"dose_to_medium"`` (default) or ``"dose_to_water"`` — a
|
|
172
|
+
scoring-OUTPUT selection (see :func:`_deposit_weight_for` and
|
|
173
|
+
:mod:`pyradmc.scoring.dose_to_water`): transport is identical, only
|
|
174
|
+
the per-deposit tally weighting differs, and the energy books stay
|
|
175
|
+
physical in both modes. Requires ``transport_electrons=True``.
|
|
176
|
+
deposit_resolution_cm
|
|
177
|
+
Longest piece a half-substep's continuous energy loss is filed as, in
|
|
178
|
+
cm. ``None`` (default) files it as one point deposit at the half-step
|
|
179
|
+
midpoint — byte-identical to every result produced before this
|
|
180
|
+
existed. A value splits the half-step into equal pieces no longer than
|
|
181
|
+
it, depositing an equal share at each piece's midpoint.
|
|
182
|
+
|
|
183
|
+
Set it when scoring **below the transport voxel scale**. There, the
|
|
184
|
+
midpoint deposit prints the voxel lattice onto the dose: substeps are
|
|
185
|
+
capped at voxel faces, so their midpoints pile at voxel centres and
|
|
186
|
+
the sub-voxel profile becomes a tent. A natural value is the finest
|
|
187
|
+
bin the scorer resolves — see
|
|
188
|
+
:attr:`pyradmc.scoring.cylinder.CylindricalScoringGrid.finest_resolution_cm`
|
|
189
|
+
and :attr:`pyradmc.scoring.grid.ScoringGrid.finest_resolution_cm`.
|
|
190
|
+
Leave it ``None`` when scoring at voxel resolution, where it buys
|
|
191
|
+
nothing and costs time.
|
|
192
|
+
|
|
193
|
+
It moves no energy and changes no trajectory or random stream — only
|
|
194
|
+
where a deposit is filed — so the energy books and the transported
|
|
195
|
+
histories are identical either way.
|
|
196
|
+
step_energy_fraction
|
|
197
|
+
Maximum fraction of CSDA range per electron substep; ``None``
|
|
198
|
+
(default) resolves to the selected ``msc_model``'s validated
|
|
199
|
+
fraction (:func:`~pyradmc.transport.electron.default_step_energy_fraction`
|
|
200
|
+
— 0.20 for the shipped ``"gs"`` configuration, 0.05 for the
|
|
201
|
+
``"gaussian"`` instrument). A measurement instrument for substep
|
|
202
|
+
resolution studies; changing a *default* is a maintainer decision
|
|
203
|
+
gated on the validation tier. ``msc_model`` semantics live on
|
|
204
|
+
:func:`~pyradmc.transport.electron.electron_steps`.
|
|
205
|
+
progress
|
|
206
|
+
Optional callback invoked with a :class:`~pyradmc.progress.ProgressEvent`
|
|
207
|
+
once per completed batch (``n_batches`` ticks total, each covering
|
|
208
|
+
``n_histories / n_batches`` histories). See
|
|
209
|
+
:mod:`pyradmc.progress` — the same tick cadence as
|
|
210
|
+
:meth:`~pyradmc.backends.warp.engine.WarpEngine.run`, so a callback
|
|
211
|
+
written against one backend behaves identically against the other.
|
|
212
|
+
concurrent_batches
|
|
213
|
+
Scheduling hint shared with the Warp API. The single-history reference
|
|
214
|
+
oracle is deliberately sequential, so any positive value is accepted
|
|
215
|
+
and has no effect.
|
|
216
|
+
"""
|
|
217
|
+
if n_histories < 1:
|
|
218
|
+
raise ValueError(f"need at least one history, got {n_histories}")
|
|
219
|
+
if concurrent_batches < 1:
|
|
220
|
+
raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
|
|
221
|
+
if n_histories % n_batches != 0:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"n_histories={n_histories} not divisible by n_batches={n_batches}; "
|
|
224
|
+
"unequal batches would weight batch means inconsistently"
|
|
225
|
+
)
|
|
226
|
+
if primary_kind not in ("photon", "electron"):
|
|
227
|
+
raise ValueError(f"unknown primary_kind {primary_kind!r}")
|
|
228
|
+
deposit_weight = _deposit_weight_for(
|
|
229
|
+
scoring_mode, self.cross_sections, ecut, transport_electrons
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
scorer = BatchedDoseScorer(
|
|
233
|
+
scoring_grid if scoring_grid is not None else self.grid, n_batches
|
|
234
|
+
)
|
|
235
|
+
per_batch = n_histories // n_batches
|
|
236
|
+
energy_emitted = 0.0
|
|
237
|
+
energy_escaped = 0.0
|
|
238
|
+
emitter = ProgressEmitter(progress, n_histories)
|
|
239
|
+
|
|
240
|
+
history = 0
|
|
241
|
+
for _ in range(n_batches):
|
|
242
|
+
for _ in range(per_batch):
|
|
243
|
+
state = self.rng.init_state(seed, history)
|
|
244
|
+
history += 1
|
|
245
|
+
primary = source.emit(state)
|
|
246
|
+
kind_name = primary.kind if primary.kind is not None else primary_kind
|
|
247
|
+
kind = _KIND_TO_PARTICLE[kind_name]
|
|
248
|
+
# A positron primary will annihilate at rest, injecting 2*m_e c^2 of
|
|
249
|
+
# photons from rest mass that its kinetic energy does not account for.
|
|
250
|
+
# (For a photon that pair-produces, that 1.022 MeV is already inside
|
|
251
|
+
# the photon's energy; a positron primary brings it as rest mass.)
|
|
252
|
+
# Count it so the emitted = deposited + escaped ledger stays exact.
|
|
253
|
+
rest_mass = 2.0 * ELECTRON_MASS_MEV if kind == POSITRON else 0.0
|
|
254
|
+
energy_emitted += primary.weight * (primary.energy + rest_mass)
|
|
255
|
+
energy_escaped += transport_history(
|
|
256
|
+
kind,
|
|
257
|
+
primary.energy,
|
|
258
|
+
primary.x,
|
|
259
|
+
primary.y,
|
|
260
|
+
primary.z,
|
|
261
|
+
primary.ux,
|
|
262
|
+
primary.uy,
|
|
263
|
+
primary.uz,
|
|
264
|
+
self.grid,
|
|
265
|
+
self.cross_sections,
|
|
266
|
+
state,
|
|
267
|
+
scorer.deposit_at,
|
|
268
|
+
pcut,
|
|
269
|
+
ecut,
|
|
270
|
+
transport_electrons,
|
|
271
|
+
weight=primary.weight,
|
|
272
|
+
deposit_weight=deposit_weight,
|
|
273
|
+
step_energy_fraction=step_energy_fraction,
|
|
274
|
+
deposit_resolution_cm=deposit_resolution_cm,
|
|
275
|
+
msc_model=msc_model,
|
|
276
|
+
)
|
|
277
|
+
scorer.end_batch(per_batch)
|
|
278
|
+
emitter.tick(per_batch)
|
|
279
|
+
|
|
280
|
+
dose = scorer.finalize()
|
|
281
|
+
return TransportResult(
|
|
282
|
+
dose=dose.dose,
|
|
283
|
+
dose_sigma=dose.dose_sigma,
|
|
284
|
+
energy_emitted=energy_emitted,
|
|
285
|
+
energy_deposited=dose.energy_deposited,
|
|
286
|
+
energy_escaped=energy_escaped,
|
|
287
|
+
energy_unscored=dose.energy_unscored,
|
|
288
|
+
n_histories=n_histories,
|
|
289
|
+
n_batches=n_batches,
|
|
290
|
+
scoring_mode=scoring_mode,
|
|
291
|
+
provenance=self._provenance(
|
|
292
|
+
seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def run_dij(
|
|
297
|
+
self,
|
|
298
|
+
source: BeamletSource,
|
|
299
|
+
n_histories_per_beamlet: int,
|
|
300
|
+
n_batches: int,
|
|
301
|
+
seed: int,
|
|
302
|
+
pcut: float = PCUT_MEV,
|
|
303
|
+
ecut: float = ECUT_MEV,
|
|
304
|
+
transport_electrons: bool = True,
|
|
305
|
+
truncation: float = DIJ_TRUNCATION_RELATIVE,
|
|
306
|
+
correlated: bool = True,
|
|
307
|
+
scoring_grid: ScoringGrid | None = None,
|
|
308
|
+
scoring_mode: str = "dose_to_medium",
|
|
309
|
+
step_energy_fraction: float | None = None,
|
|
310
|
+
deposit_resolution_cm: float | None = None,
|
|
311
|
+
msc_model: str = "gs",
|
|
312
|
+
progress: ProgressCallback | None = None,
|
|
313
|
+
) -> DijResult:
|
|
314
|
+
"""Compute the beamlet-resolved dose influence matrix over the lattice.
|
|
315
|
+
|
|
316
|
+
History-to-beamlet mapping — the project-wide convention every backend
|
|
317
|
+
follows: history ``h`` feeds beamlet ``j = h // n_histories_per_beamlet``,
|
|
318
|
+
and within a beamlet, batch ``b`` owns the contiguous slice of
|
|
319
|
+
``n_histories_per_beamlet / n_batches`` histories starting at
|
|
320
|
+
``j * n_histories_per_beamlet + b * (that slice length)``. Streams are pure
|
|
321
|
+
functions of ``(seed, h)``, so the Dij is bit-reproducible on one target
|
|
322
|
+
regardless of how a backend schedules the transport, and a 1x1 lattice
|
|
323
|
+
reproduces the open-field :meth:`run` bit for bit (test-pinned).
|
|
324
|
+
|
|
325
|
+
Correlated sampling changes the *stream key* only: with
|
|
326
|
+
``correlated=True``, the history at within-beamlet index
|
|
327
|
+
``rw = h - j * n_histories_per_beamlet`` draws the stream ``(seed, rw)``
|
|
328
|
+
instead of ``(seed, h)``, so corresponding histories of every beamlet
|
|
329
|
+
replay the same random sequence — same within-bixel entry offset, same
|
|
330
|
+
interaction sequence — and only the beamlet's position differs. Beamlet
|
|
331
|
+
assignment, batching, scoring and the energy books are untouched, and on
|
|
332
|
+
a 1x1 lattice ``rw == h``, so the open-field anchor above holds in both
|
|
333
|
+
modes (test-pinned).
|
|
334
|
+
|
|
335
|
+
Every deposit of a history's whole secondary family scores into its
|
|
336
|
+
beamlet's column: the columns partition the open-field dose exactly.
|
|
337
|
+
Column doses are per emitted history *of that beamlet*, MeV/g.
|
|
338
|
+
|
|
339
|
+
Parameters mirror :meth:`run`; the two Dij-specific ones:
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
n_histories_per_beamlet
|
|
344
|
+
Histories per beamlet (equal by design — stratified, not sampled);
|
|
345
|
+
must be divisible by ``n_batches``.
|
|
346
|
+
truncation
|
|
347
|
+
Per-column relative truncation threshold. Accuracy-defining
|
|
348
|
+
(AGENTS.md 2.8): the default is :data:`pyradmc.DIJ_TRUNCATION_RELATIVE`
|
|
349
|
+
and a different value in a call is a visible, greppable decision.
|
|
350
|
+
correlated
|
|
351
|
+
Key streams on the within-beamlet index so columns share random
|
|
352
|
+
sequences (correlated sampling). **This is the shipped
|
|
353
|
+
configuration** (default True): the noise/bias study
|
|
354
|
+
(``examples/noise_bias_study.py``) found it halves the
|
|
355
|
+
renormalized plan-dose error at matched per-beamlet sigma, in water
|
|
356
|
+
and through a heterogeneity, and never worse on raw plan quality.
|
|
357
|
+
``correlated=False`` selects the independent mapping and exists
|
|
358
|
+
only as a **test instrument** (AGENTS.md 2.10): it isolates the
|
|
359
|
+
column independence the fluence-sum identity's quadrature sigma
|
|
360
|
+
needs. A correlated Dij's columns are statistically dependent —
|
|
361
|
+
per-column sigmas stay valid, but never combine sigmas across
|
|
362
|
+
columns in quadrature. The result records the mode in
|
|
363
|
+
``DijResult.correlated``.
|
|
364
|
+
scoring_grid
|
|
365
|
+
Dose grid the Dij columns live on; semantics as in :meth:`run`. The
|
|
366
|
+
memory lever for plan-scale problems: the dense per-group buffers and
|
|
367
|
+
the sparse Dij all scale with the *scoring* voxel count, so a coarser
|
|
368
|
+
dose grid shrinks them cubically while transport keeps the full CT
|
|
369
|
+
resolution.
|
|
370
|
+
scoring_mode
|
|
371
|
+
Tally weighting of the columns, as in :meth:`run`; recorded in
|
|
372
|
+
``DijResult.scoring_mode``.
|
|
373
|
+
deposit_resolution_cm
|
|
374
|
+
Sub-substep deposit resolution, as in :meth:`run`. Relevant only when
|
|
375
|
+
``scoring_grid`` is finer than the transport grid, which for a Dij is
|
|
376
|
+
unusual — the dose grid is normally the memory lever and therefore
|
|
377
|
+
coarser, where the default ``None`` is both correct and cheaper.
|
|
378
|
+
progress
|
|
379
|
+
Optional callback, as in :meth:`run`. Ticks once per completed batch
|
|
380
|
+
(``n_batches`` ticks total), each covering
|
|
381
|
+
``n_beamlets * n_histories_per_beamlet / n_batches`` histories — this
|
|
382
|
+
engine iterates batch-outer, beamlet-inner, so a batch spans every
|
|
383
|
+
beamlet. :meth:`~pyradmc.backends.warp.engine.WarpEngine.run_dij`
|
|
384
|
+
ticks on a different axis (per beamlet group, not per batch): both
|
|
385
|
+
reach the same total, but tick count and spacing differ between
|
|
386
|
+
backends. Treat ``histories_done / histories_total`` as the portable
|
|
387
|
+
signal (see :mod:`pyradmc.progress`).
|
|
388
|
+
"""
|
|
389
|
+
if n_histories_per_beamlet < 1:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"need at least one history per beamlet, got {n_histories_per_beamlet}"
|
|
392
|
+
)
|
|
393
|
+
if n_histories_per_beamlet % n_batches != 0:
|
|
394
|
+
raise ValueError(
|
|
395
|
+
f"n_histories_per_beamlet={n_histories_per_beamlet} not divisible by "
|
|
396
|
+
f"n_batches={n_batches}; unequal batches would weight batch means inconsistently"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
deposit_weight = _deposit_weight_for(
|
|
400
|
+
scoring_mode, self.cross_sections, ecut, transport_electrons
|
|
401
|
+
)
|
|
402
|
+
n_beamlets = source.n_beamlets
|
|
403
|
+
per_batch = n_histories_per_beamlet // n_batches
|
|
404
|
+
scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
|
|
405
|
+
scorer = BatchedBeamletScorer(scoring, n_batches, n_beamlets)
|
|
406
|
+
energy_emitted = 0.0
|
|
407
|
+
energy_escaped = 0.0
|
|
408
|
+
emitter = ProgressEmitter(progress, n_beamlets * n_histories_per_beamlet)
|
|
409
|
+
|
|
410
|
+
for batch in range(n_batches):
|
|
411
|
+
for beamlet in range(n_beamlets):
|
|
412
|
+
deposit = partial(scorer.deposit_at, beamlet)
|
|
413
|
+
for r in range(per_batch):
|
|
414
|
+
rw = batch * per_batch + r
|
|
415
|
+
h = beamlet * n_histories_per_beamlet + rw
|
|
416
|
+
state = self.rng.init_state(seed, rw if correlated else h)
|
|
417
|
+
primary = source.emit(beamlet, state)
|
|
418
|
+
energy_emitted += primary.weight * primary.energy
|
|
419
|
+
energy_escaped += transport_history(
|
|
420
|
+
PHOTON,
|
|
421
|
+
primary.energy,
|
|
422
|
+
primary.x,
|
|
423
|
+
primary.y,
|
|
424
|
+
primary.z,
|
|
425
|
+
primary.ux,
|
|
426
|
+
primary.uy,
|
|
427
|
+
primary.uz,
|
|
428
|
+
self.grid,
|
|
429
|
+
self.cross_sections,
|
|
430
|
+
state,
|
|
431
|
+
deposit,
|
|
432
|
+
pcut,
|
|
433
|
+
ecut,
|
|
434
|
+
transport_electrons,
|
|
435
|
+
weight=primary.weight,
|
|
436
|
+
deposit_weight=deposit_weight,
|
|
437
|
+
step_energy_fraction=step_energy_fraction,
|
|
438
|
+
deposit_resolution_cm=deposit_resolution_cm,
|
|
439
|
+
msc_model=msc_model,
|
|
440
|
+
)
|
|
441
|
+
scorer.end_batch(per_batch)
|
|
442
|
+
emitter.tick(n_beamlets * per_batch)
|
|
443
|
+
|
|
444
|
+
block = scorer.finalize()
|
|
445
|
+
assembler = DijAssembler(
|
|
446
|
+
grid_shape=scoring.shape,
|
|
447
|
+
n_beamlets=n_beamlets,
|
|
448
|
+
n_histories_per_beamlet=n_histories_per_beamlet,
|
|
449
|
+
n_batches=n_batches,
|
|
450
|
+
truncation=truncation,
|
|
451
|
+
correlated=correlated,
|
|
452
|
+
scoring_mode=scoring_mode,
|
|
453
|
+
provenance=self._provenance(
|
|
454
|
+
seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
assembler.add_block(0, block.dose, block.sigma)
|
|
458
|
+
return assembler.finalize(
|
|
459
|
+
energy_emitted=energy_emitted,
|
|
460
|
+
energy_deposited=block.energy_deposited,
|
|
461
|
+
energy_escaped=energy_escaped,
|
|
462
|
+
energy_unscored=block.energy_unscored,
|
|
463
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Backend-agnostic transport results.
|
|
2
|
+
|
|
3
|
+
Every backend returns the same result type from ``run``, so tests and callers compare
|
|
4
|
+
backends without caring which engine produced what. Defined here, above the backend
|
|
5
|
+
subpackages, because a result is not backend code (AGENTS.md section 3: backends hold
|
|
6
|
+
launch and memory management only).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
__all__ = ["RunProvenance", "TransportResult"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RunProvenance:
|
|
20
|
+
"""The configuration a result was produced under, carried with the result.
|
|
21
|
+
|
|
22
|
+
A dose array outlives the process that made it: it is archived, handed to an
|
|
23
|
+
optimizer, attached to a plan, compared against a run from six months ago. Every
|
|
24
|
+
field here changes the numbers, and none of them is recoverable from the array
|
|
25
|
+
afterwards — so a result that does not carry them is not reproducible, however
|
|
26
|
+
carefully the run was scripted.
|
|
27
|
+
|
|
28
|
+
This is a record, not a control surface: constructing one does not configure
|
|
29
|
+
anything, and the engines fill it in from the arguments they were actually
|
|
30
|
+
called with (a resolved ``step_energy_fraction``, not the ``None`` the caller
|
|
31
|
+
may have passed).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
version: str
|
|
35
|
+
"""``pyradmc.__version__`` of the engine that produced the result."""
|
|
36
|
+
backend: str
|
|
37
|
+
"""``"ref"`` or ``"warp"``."""
|
|
38
|
+
device: str
|
|
39
|
+
"""``"cpu"`` or a CUDA device such as ``"cuda:0"``. Results are bit-reproducible
|
|
40
|
+
for a given seed *on one device*, never across devices (AGENTS.md section 2.3)."""
|
|
41
|
+
seed: int
|
|
42
|
+
"""Global seed; history ``i`` used the stream ``(seed, i)``."""
|
|
43
|
+
pcut_mev: float
|
|
44
|
+
"""Photon transport cutoff in MeV."""
|
|
45
|
+
ecut_mev: float
|
|
46
|
+
"""Electron transport and production cutoff, kinetic energy in MeV."""
|
|
47
|
+
msc_model: str
|
|
48
|
+
"""Multiple-scattering model: ``"gs"`` (shipped) or ``"gaussian"``."""
|
|
49
|
+
step_energy_fraction: float
|
|
50
|
+
"""Resolved electron substep energy-loss fraction, never ``None``."""
|
|
51
|
+
cross_sections: str
|
|
52
|
+
"""Cross-section provenance, from
|
|
53
|
+
:attr:`~pyradmc.data.interface.CrossSectionSource.provenance` — the compiled
|
|
54
|
+
library citation for a tabulated source, the parameterization for the analytic
|
|
55
|
+
one. This is the field that distinguishes two otherwise identical runs."""
|
|
56
|
+
deposit_resolution_cm: float | None = None
|
|
57
|
+
"""Longest piece a half-substep's continuous energy loss was filed as, in cm.
|
|
58
|
+
|
|
59
|
+
``None`` is the single midpoint deposit — the default, and what every result
|
|
60
|
+
produced before this option existed used. A value moves dose *within* a
|
|
61
|
+
transport voxel (never between voxels, and never any total), so two runs that
|
|
62
|
+
differ only here agree on the energy books and on any dose scored at voxel
|
|
63
|
+
resolution, and can differ below it. That is exactly why it is recorded: it is
|
|
64
|
+
not recoverable from the dose array."""
|
|
65
|
+
|
|
66
|
+
def summary(self) -> str:
|
|
67
|
+
"""One-line human-readable digest, for logs and file headers."""
|
|
68
|
+
return (
|
|
69
|
+
f"pyradmc {self.version} {self.backend}/{self.device} seed={self.seed} "
|
|
70
|
+
f"pcut={self.pcut_mev} ecut={self.ecut_mev} msc={self.msc_model} "
|
|
71
|
+
f"step={self.step_energy_fraction} deposit_res={self.deposit_resolution_cm} "
|
|
72
|
+
f"xs=[{self.cross_sections}]"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class TransportResult:
|
|
78
|
+
"""One engine run: batched dose estimate plus exact energy bookkeeping.
|
|
79
|
+
|
|
80
|
+
``energy_emitted == energy_deposited + energy_unscored + energy_escaped`` holds
|
|
81
|
+
to accumulation precision of the producing backend — float64 exact for ``ref``,
|
|
82
|
+
float32 transport arithmetic plus scoring quantization for ``warp`` — and is
|
|
83
|
+
asserted in the integration tier at each backend's documented tolerance.
|
|
84
|
+
|
|
85
|
+
``energy_escaped`` is a *ledger*, not purely physical escape: it
|
|
86
|
+
also carries the net weight-energy Russian roulette removes from the transported
|
|
87
|
+
population (kills positive, survivor boosts negative), which is exactly what
|
|
88
|
+
keeps the identity above exact per run under variance reduction.
|
|
89
|
+
|
|
90
|
+
``energy_unscored`` is deposit energy that landed inside the transport grid but
|
|
91
|
+
outside the scoring grid (decoupled dose grid). It is exactly zero when
|
|
92
|
+
the scoring grid covers the transport grid — in particular for the default
|
|
93
|
+
score-on-the-transport-grid configuration.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
dose: np.ndarray
|
|
97
|
+
"""Per-voxel dose on the scoring grid, MeV/g per emitted history."""
|
|
98
|
+
dose_sigma: np.ndarray
|
|
99
|
+
"""Per-voxel 1-sigma standard error from batch statistics."""
|
|
100
|
+
energy_emitted: float
|
|
101
|
+
energy_deposited: float
|
|
102
|
+
energy_escaped: float
|
|
103
|
+
n_histories: int
|
|
104
|
+
n_batches: int
|
|
105
|
+
energy_unscored: float = 0.0
|
|
106
|
+
scoring_mode: str = "dose_to_medium"
|
|
107
|
+
"""Tally weighting the dose was produced under: ``"dose_to_medium"``
|
|
108
|
+
or ``"dose_to_water"``. The energy books are physical in both modes."""
|
|
109
|
+
provenance: RunProvenance | None = None
|
|
110
|
+
"""How this result was produced; see :class:`RunProvenance`.
|
|
111
|
+
|
|
112
|
+
Optional on the dataclass so that a hand-assembled result (a test instrument, a
|
|
113
|
+
reload from disk) need not fabricate one, but **every engine run populates it** —
|
|
114
|
+
that contract is test-pinned rather than expressed in the type, because it is a
|
|
115
|
+
property of the engines, not of the container."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Subpackage placeholder; see AGENTS.md before adding code."""
|