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.
Files changed (72) hide show
  1. pyradmc/__init__.py +308 -0
  2. pyradmc/adapters/__init__.py +9 -0
  3. pyradmc/adapters/ct.py +244 -0
  4. pyradmc/backends/__init__.py +1 -0
  5. pyradmc/backends/ref/__init__.py +5 -0
  6. pyradmc/backends/ref/engine.py +463 -0
  7. pyradmc/backends/results.py +115 -0
  8. pyradmc/backends/warp/__init__.py +1 -0
  9. pyradmc/backends/warp/engine.py +3451 -0
  10. pyradmc/backends/warp/kernels.py +2401 -0
  11. pyradmc/backends/warp/physics.py +211 -0
  12. pyradmc/backends/warp/presolve.py +742 -0
  13. pyradmc/data/__init__.py +1 -0
  14. pyradmc/data/analytic.py +321 -0
  15. pyradmc/data/berger_seltzer.py +240 -0
  16. pyradmc/data/goudsmit_saunderson.py +1122 -0
  17. pyradmc/data/handles.py +34 -0
  18. pyradmc/data/interface.py +437 -0
  19. pyradmc/data/materials.py +345 -0
  20. pyradmc/data/tables.py +362 -0
  21. pyradmc/data/tabulated/__init__.py +10 -0
  22. pyradmc/data/tabulated/build.py +211 -0
  23. pyradmc/data/tabulated/eedl.py +315 -0
  24. pyradmc/data/tabulated/endf.py +258 -0
  25. pyradmc/data/tabulated/epdl.py +234 -0
  26. pyradmc/data/tabulated/format.py +102 -0
  27. pyradmc/data/tabulated/model.py +48 -0
  28. pyradmc/data/tabulated/precompile.py +267 -0
  29. pyradmc/data/tabulated/source.py +241 -0
  30. pyradmc/geometry/__init__.py +1 -0
  31. pyradmc/geometry/collimation.py +1284 -0
  32. pyradmc/geometry/cylinder.py +118 -0
  33. pyradmc/geometry/fluence.py +147 -0
  34. pyradmc/geometry/grid.py +337 -0
  35. pyradmc/geometry/head.py +599 -0
  36. pyradmc/geometry/phasespace.py +769 -0
  37. pyradmc/geometry/source.py +1195 -0
  38. pyradmc/geometry/spectrum.py +310 -0
  39. pyradmc/physics/__init__.py +1 -0
  40. pyradmc/physics/brems.py +83 -0
  41. pyradmc/physics/channel.py +55 -0
  42. pyradmc/physics/compton.py +118 -0
  43. pyradmc/physics/direction.py +81 -0
  44. pyradmc/physics/gs.py +166 -0
  45. pyradmc/physics/moller.py +87 -0
  46. pyradmc/physics/msc.py +54 -0
  47. pyradmc/physics/path.py +35 -0
  48. pyradmc/physics/rayleigh.py +118 -0
  49. pyradmc/physics/roulette.py +34 -0
  50. pyradmc/progress.py +96 -0
  51. pyradmc/py.typed +0 -0
  52. pyradmc/rng/__init__.py +12 -0
  53. pyradmc/rng/host.py +48 -0
  54. pyradmc/rng/interface.py +64 -0
  55. pyradmc/rng/warp_shim.py +81 -0
  56. pyradmc/scoring/__init__.py +1 -0
  57. pyradmc/scoring/cylinder.py +513 -0
  58. pyradmc/scoring/dij.py +434 -0
  59. pyradmc/scoring/dose.py +207 -0
  60. pyradmc/scoring/dose_to_water.py +84 -0
  61. pyradmc/scoring/grid.py +219 -0
  62. pyradmc/study.py +150 -0
  63. pyradmc/transport/__init__.py +1 -0
  64. pyradmc/transport/electron.py +481 -0
  65. pyradmc/transport/history.py +150 -0
  66. pyradmc/transport/particles.py +102 -0
  67. pyradmc/transport/photon.py +348 -0
  68. pyradmc-0.2.0.dist-info/METADATA +162 -0
  69. pyradmc-0.2.0.dist-info/RECORD +72 -0
  70. pyradmc-0.2.0.dist-info/WHEEL +4 -0
  71. pyradmc-0.2.0.dist-info/licenses/LICENSE +201 -0
  72. 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."""