Skip to content

API reference

Everything on this page is importable directly from the top-level pyradmc namespace and is supported and versioned. Anything not documented here is an implementation detail that may move between releases, even if it is importable.

from pyradmc import VoxelGrid, ReferenceEngine, WarpEngine  # etc.

Re-exports are lazy, so importing pyradmc costs nothing and does not pull in NumPy, SciPy, Warp or SimpleITK. A core-only install can name WarpEngine without having warp installed; touching it then raises an error naming the extra to install.

Engines

pyradmc.backends.ref.engine.ReferenceEngine dataclass

Single-threaded reference photon engine over a voxel grid.

Source code in pyradmc/backends/ref/engine.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
@dataclass(frozen=True)
class ReferenceEngine:
    """Single-threaded reference photon engine over a voxel grid."""

    grid: VoxelGrid
    cross_sections: CrossSectionSource
    rng: RNG

    def _provenance(
        self,
        seed: int,
        pcut: float,
        ecut: float,
        msc_model: str,
        step_energy_fraction: float | None,
        deposit_resolution_cm: float | None,
    ) -> RunProvenance:
        """Record the configuration this run actually used.

        ``step_energy_fraction`` is resolved here rather than stored as the caller's
        ``None``: the whole point of the record is to say what ran, and the default
        follows ``msc_model``.
        """
        return RunProvenance(
            version=__version__,
            backend="ref",
            device="cpu",
            seed=seed,
            pcut_mev=pcut,
            ecut_mev=ecut,
            msc_model=msc_model,
            step_energy_fraction=(
                default_step_energy_fraction(msc_model)
                if step_energy_fraction is None
                else step_energy_fraction
            ),
            cross_sections=self.cross_sections.provenance,
            deposit_resolution_cm=deposit_resolution_cm,
        )

    def run(
        self,
        source: Source,
        n_histories: int,
        n_batches: int,
        seed: int,
        pcut: float = PCUT_MEV,
        ecut: float = ECUT_MEV,
        transport_electrons: bool = True,
        primary_kind: str = "photon",
        scoring_grid: ScoringGeometry | None = None,
        scoring_mode: str = "dose_to_medium",
        step_energy_fraction: float | None = None,
        deposit_resolution_cm: float | None = None,
        msc_model: str = "gs",
        progress: ProgressCallback | None = None,
        concurrent_batches: int = 1,
    ) -> TransportResult:
        """Transport ``n_histories`` primaries in ``n_batches`` equal batches.

        Parameters
        ----------
        source
            Primary source; its geometry is particle-agnostic (see ``primary_kind``).
        n_histories
            Total primaries; must be divisible by ``n_batches`` so every batch mean
            carries equal statistical weight.
        n_batches
            Batches for the sigma estimate (AGENTS.md section 2.4).
        seed
            Global seed; history ``i`` uses the stream ``(seed, i)``, so the result
            is bit-reproducible for a given target and seed regardless of batching.
        pcut, ecut
            Photon and electron cutoffs in MeV. Accuracy-defining (AGENTS.md
            section 2.8); the defaults are the project-wide values and changing one
            in a call is a visible, greppable decision.
        transport_electrons
            False selects the KERMA approximation (charged secondaries
            deposit at their creation voxel) — the explicit option docs/decisions.md
            keeps for photon-only physics tests.
        primary_kind
            ``"photon"`` (default) or ``"electron"``: the fallback kind for sources
            whose emitted :class:`~pyradmc.geometry.source.Primary` leaves ``kind``
            unset (the monoenergetic beam sources). A phase-space source overrides
            it per record, so this argument is ignored for that source. The electron
            option exists for validating electron transport against ranges; electron
            *beams* as a clinical modality remain out of scope (AGENTS.md 6).
        scoring_grid
            Scoring geometry to accumulate dose on (decoupled scoring). ``None``
            (default) scores on the transport grid — byte-identical to the
            engine before scoring grids existed. Build a coarser, offset or
            subregion grid with :meth:`pyradmc.scoring.grid.ScoringGrid.rebin`, or
            a depth-by-radial-shell pencil-beam kernel binning with
            :meth:`pyradmc.scoring.cylinder.CylindricalScoringGrid.for_grid`,
            **from the same transport grid handed to this engine**; deposits it
            does not cover are booked to ``TransportResult.energy_unscored``, so
            ``emitted == deposited + unscored + escaped`` stays exact. Transport
            never sees this geometry: the streams, and hence the physics, are
            invariant to it — which is why a cylindrical binning is a readout
            choice and not a physics flag (AGENTS.md 2.10).
        scoring_mode
            ``"dose_to_medium"`` (default) or ``"dose_to_water"`` — a
            scoring-OUTPUT selection (see :func:`_deposit_weight_for` and
            :mod:`pyradmc.scoring.dose_to_water`): transport is identical, only
            the per-deposit tally weighting differs, and the energy books stay
            physical in both modes. Requires ``transport_electrons=True``.
        deposit_resolution_cm
            Longest piece a half-substep's continuous energy loss is filed as, in
            cm. ``None`` (default) files it as one point deposit at the half-step
            midpoint — byte-identical to every result produced before this
            existed. A value splits the half-step into equal pieces no longer than
            it, depositing an equal share at each piece's midpoint.

            Set it when scoring **below the transport voxel scale**. There, the
            midpoint deposit prints the voxel lattice onto the dose: substeps are
            capped at voxel faces, so their midpoints pile at voxel centres and
            the sub-voxel profile becomes a tent. A natural value is the finest
            bin the scorer resolves — see
            :attr:`pyradmc.scoring.cylinder.CylindricalScoringGrid.finest_resolution_cm`
            and :attr:`pyradmc.scoring.grid.ScoringGrid.finest_resolution_cm`.
            Leave it ``None`` when scoring at voxel resolution, where it buys
            nothing and costs time.

            It moves no energy and changes no trajectory or random stream — only
            where a deposit is filed — so the energy books and the transported
            histories are identical either way.
        step_energy_fraction
            Maximum fraction of CSDA range per electron substep; ``None``
            (default) resolves to the selected ``msc_model``'s validated
            fraction (:func:`~pyradmc.transport.electron.default_step_energy_fraction`
            — 0.20 for the shipped ``"gs"`` configuration, 0.05 for the
            ``"gaussian"`` instrument). A measurement instrument for substep
            resolution studies; changing a *default* is a maintainer decision
            gated on the validation tier. ``msc_model`` semantics live on
            :func:`~pyradmc.transport.electron.electron_steps`.
        progress
            Optional callback invoked with a :class:`~pyradmc.progress.ProgressEvent`
            once per completed batch (``n_batches`` ticks total, each covering
            ``n_histories / n_batches`` histories). See
            :mod:`pyradmc.progress` — the same tick cadence as
            :meth:`~pyradmc.backends.warp.engine.WarpEngine.run`, so a callback
            written against one backend behaves identically against the other.
        concurrent_batches
            Scheduling hint shared with the Warp API. The single-history reference
            oracle is deliberately sequential, so any positive value is accepted
            and has no effect.
        """
        if n_histories < 1:
            raise ValueError(f"need at least one history, got {n_histories}")
        if concurrent_batches < 1:
            raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
        if n_histories % n_batches != 0:
            raise ValueError(
                f"n_histories={n_histories} not divisible by n_batches={n_batches}; "
                "unequal batches would weight batch means inconsistently"
            )
        if primary_kind not in ("photon", "electron"):
            raise ValueError(f"unknown primary_kind {primary_kind!r}")
        deposit_weight = _deposit_weight_for(
            scoring_mode, self.cross_sections, ecut, transport_electrons
        )

        scorer = BatchedDoseScorer(
            scoring_grid if scoring_grid is not None else self.grid, n_batches
        )
        per_batch = n_histories // n_batches
        energy_emitted = 0.0
        energy_escaped = 0.0
        emitter = ProgressEmitter(progress, n_histories)

        history = 0
        for _ in range(n_batches):
            for _ in range(per_batch):
                state = self.rng.init_state(seed, history)
                history += 1
                primary = source.emit(state)
                kind_name = primary.kind if primary.kind is not None else primary_kind
                kind = _KIND_TO_PARTICLE[kind_name]
                # A positron primary will annihilate at rest, injecting 2*m_e c^2 of
                # photons from rest mass that its kinetic energy does not account for.
                # (For a photon that pair-produces, that 1.022 MeV is already inside
                # the photon's energy; a positron primary brings it as rest mass.)
                # Count it so the emitted = deposited + escaped ledger stays exact.
                rest_mass = 2.0 * ELECTRON_MASS_MEV if kind == POSITRON else 0.0
                energy_emitted += primary.weight * (primary.energy + rest_mass)
                energy_escaped += transport_history(
                    kind,
                    primary.energy,
                    primary.x,
                    primary.y,
                    primary.z,
                    primary.ux,
                    primary.uy,
                    primary.uz,
                    self.grid,
                    self.cross_sections,
                    state,
                    scorer.deposit_at,
                    pcut,
                    ecut,
                    transport_electrons,
                    weight=primary.weight,
                    deposit_weight=deposit_weight,
                    step_energy_fraction=step_energy_fraction,
                    deposit_resolution_cm=deposit_resolution_cm,
                    msc_model=msc_model,
                )
            scorer.end_batch(per_batch)
            emitter.tick(per_batch)

        dose = scorer.finalize()
        return TransportResult(
            dose=dose.dose,
            dose_sigma=dose.dose_sigma,
            energy_emitted=energy_emitted,
            energy_deposited=dose.energy_deposited,
            energy_escaped=energy_escaped,
            energy_unscored=dose.energy_unscored,
            n_histories=n_histories,
            n_batches=n_batches,
            scoring_mode=scoring_mode,
            provenance=self._provenance(
                seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
            ),
        )

    def run_dij(
        self,
        source: BeamletSource,
        n_histories_per_beamlet: int,
        n_batches: int,
        seed: int,
        pcut: float = PCUT_MEV,
        ecut: float = ECUT_MEV,
        transport_electrons: bool = True,
        truncation: float = DIJ_TRUNCATION_RELATIVE,
        correlated: bool = True,
        scoring_grid: ScoringGrid | None = None,
        scoring_mode: str = "dose_to_medium",
        step_energy_fraction: float | None = None,
        deposit_resolution_cm: float | None = None,
        msc_model: str = "gs",
        progress: ProgressCallback | None = None,
    ) -> DijResult:
        """Compute the beamlet-resolved dose influence matrix over the lattice.

        History-to-beamlet mapping — the project-wide convention every backend
        follows: history ``h`` feeds beamlet ``j = h // n_histories_per_beamlet``,
        and within a beamlet, batch ``b`` owns the contiguous slice of
        ``n_histories_per_beamlet / n_batches`` histories starting at
        ``j * n_histories_per_beamlet + b * (that slice length)``. Streams are pure
        functions of ``(seed, h)``, so the Dij is bit-reproducible on one target
        regardless of how a backend schedules the transport, and a 1x1 lattice
        reproduces the open-field :meth:`run` bit for bit (test-pinned).

        Correlated sampling changes the *stream key* only: with
        ``correlated=True``, the history at within-beamlet index
        ``rw = h - j * n_histories_per_beamlet`` draws the stream ``(seed, rw)``
        instead of ``(seed, h)``, so corresponding histories of every beamlet
        replay the same random sequence — same within-bixel entry offset, same
        interaction sequence — and only the beamlet's position differs. Beamlet
        assignment, batching, scoring and the energy books are untouched, and on
        a 1x1 lattice ``rw == h``, so the open-field anchor above holds in both
        modes (test-pinned).

        Every deposit of a history's whole secondary family scores into its
        beamlet's column: the columns partition the open-field dose exactly.
        Column doses are per emitted history *of that beamlet*, MeV/g.

        Parameters mirror :meth:`run`; the two Dij-specific ones:

        Parameters
        ----------
        n_histories_per_beamlet
            Histories per beamlet (equal by design — stratified, not sampled);
            must be divisible by ``n_batches``.
        truncation
            Per-column relative truncation threshold. Accuracy-defining
            (AGENTS.md 2.8): the default is :data:`pyradmc.DIJ_TRUNCATION_RELATIVE`
            and a different value in a call is a visible, greppable decision.
        correlated
            Key streams on the within-beamlet index so columns share random
            sequences (correlated sampling). **This is the shipped
            configuration** (default True): the noise/bias study
            (``examples/noise_bias_study.py``) found it halves the
            renormalized plan-dose error at matched per-beamlet sigma, in water
            and through a heterogeneity, and never worse on raw plan quality.
            ``correlated=False`` selects the independent mapping and exists
            only as a **test instrument** (AGENTS.md 2.10): it isolates the
            column independence the fluence-sum identity's quadrature sigma
            needs. A correlated Dij's columns are statistically dependent —
            per-column sigmas stay valid, but never combine sigmas across
            columns in quadrature. The result records the mode in
            ``DijResult.correlated``.
        scoring_grid
            Dose grid the Dij columns live on; semantics as in :meth:`run`. The
            memory lever for plan-scale problems: the dense per-group buffers and
            the sparse Dij all scale with the *scoring* voxel count, so a coarser
            dose grid shrinks them cubically while transport keeps the full CT
            resolution.
        scoring_mode
            Tally weighting of the columns, as in :meth:`run`; recorded in
            ``DijResult.scoring_mode``.
        deposit_resolution_cm
            Sub-substep deposit resolution, as in :meth:`run`. Relevant only when
            ``scoring_grid`` is finer than the transport grid, which for a Dij is
            unusual — the dose grid is normally the memory lever and therefore
            coarser, where the default ``None`` is both correct and cheaper.
        progress
            Optional callback, as in :meth:`run`. Ticks once per completed batch
            (``n_batches`` ticks total), each covering
            ``n_beamlets * n_histories_per_beamlet / n_batches`` histories — this
            engine iterates batch-outer, beamlet-inner, so a batch spans every
            beamlet. :meth:`~pyradmc.backends.warp.engine.WarpEngine.run_dij`
            ticks on a different axis (per beamlet group, not per batch): both
            reach the same total, but tick count and spacing differ between
            backends. Treat ``histories_done / histories_total`` as the portable
            signal (see :mod:`pyradmc.progress`).
        """
        if n_histories_per_beamlet < 1:
            raise ValueError(
                f"need at least one history per beamlet, got {n_histories_per_beamlet}"
            )
        if n_histories_per_beamlet % n_batches != 0:
            raise ValueError(
                f"n_histories_per_beamlet={n_histories_per_beamlet} not divisible by "
                f"n_batches={n_batches}; unequal batches would weight batch means inconsistently"
            )

        deposit_weight = _deposit_weight_for(
            scoring_mode, self.cross_sections, ecut, transport_electrons
        )
        n_beamlets = source.n_beamlets
        per_batch = n_histories_per_beamlet // n_batches
        scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
        scorer = BatchedBeamletScorer(scoring, n_batches, n_beamlets)
        energy_emitted = 0.0
        energy_escaped = 0.0
        emitter = ProgressEmitter(progress, n_beamlets * n_histories_per_beamlet)

        for batch in range(n_batches):
            for beamlet in range(n_beamlets):
                deposit = partial(scorer.deposit_at, beamlet)
                for r in range(per_batch):
                    rw = batch * per_batch + r
                    h = beamlet * n_histories_per_beamlet + rw
                    state = self.rng.init_state(seed, rw if correlated else h)
                    primary = source.emit(beamlet, state)
                    energy_emitted += primary.weight * primary.energy
                    energy_escaped += transport_history(
                        PHOTON,
                        primary.energy,
                        primary.x,
                        primary.y,
                        primary.z,
                        primary.ux,
                        primary.uy,
                        primary.uz,
                        self.grid,
                        self.cross_sections,
                        state,
                        deposit,
                        pcut,
                        ecut,
                        transport_electrons,
                        weight=primary.weight,
                        deposit_weight=deposit_weight,
                        step_energy_fraction=step_energy_fraction,
                        deposit_resolution_cm=deposit_resolution_cm,
                        msc_model=msc_model,
                    )
            scorer.end_batch(per_batch)
            emitter.tick(n_beamlets * per_batch)

        block = scorer.finalize()
        assembler = DijAssembler(
            grid_shape=scoring.shape,
            n_beamlets=n_beamlets,
            n_histories_per_beamlet=n_histories_per_beamlet,
            n_batches=n_batches,
            truncation=truncation,
            correlated=correlated,
            scoring_mode=scoring_mode,
            provenance=self._provenance(
                seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
            ),
        )
        assembler.add_block(0, block.dose, block.sigma)
        return assembler.finalize(
            energy_emitted=energy_emitted,
            energy_deposited=block.energy_deposited,
            energy_escaped=energy_escaped,
            energy_unscored=block.energy_unscored,
        )

run

run(source: Source, n_histories: int, n_batches: int, seed: int, pcut: float = PCUT_MEV, ecut: float = ECUT_MEV, transport_electrons: bool = True, primary_kind: str = 'photon', scoring_grid: ScoringGeometry | None = None, scoring_mode: str = 'dose_to_medium', step_energy_fraction: float | None = None, deposit_resolution_cm: float | None = None, msc_model: str = 'gs', progress: ProgressCallback | None = None, concurrent_batches: int = 1) -> TransportResult

Transport n_histories primaries in n_batches equal batches.

Parameters:

Name Type Description Default
source Source

Primary source; its geometry is particle-agnostic (see primary_kind).

required
n_histories int

Total primaries; must be divisible by n_batches so every batch mean carries equal statistical weight.

required
n_batches int

Batches for the sigma estimate (AGENTS.md section 2.4).

required
seed int

Global seed; history i uses the stream (seed, i), so the result is bit-reproducible for a given target and seed regardless of batching.

required
pcut float

Photon and electron cutoffs in MeV. Accuracy-defining (AGENTS.md section 2.8); the defaults are the project-wide values and changing one in a call is a visible, greppable decision.

PCUT_MEV
ecut float

Photon and electron cutoffs in MeV. Accuracy-defining (AGENTS.md section 2.8); the defaults are the project-wide values and changing one in a call is a visible, greppable decision.

PCUT_MEV
transport_electrons bool

False selects the KERMA approximation (charged secondaries deposit at their creation voxel) — the explicit option docs/decisions.md keeps for photon-only physics tests.

True
primary_kind str

"photon" (default) or "electron": the fallback kind for sources whose emitted :class:~pyradmc.geometry.source.Primary leaves kind unset (the monoenergetic beam sources). A phase-space source overrides it per record, so this argument is ignored for that source. The electron option exists for validating electron transport against ranges; electron beams as a clinical modality remain out of scope (AGENTS.md 6).

'photon'
scoring_grid ScoringGeometry | None

Scoring geometry to accumulate dose on (decoupled scoring). None (default) scores on the transport grid — byte-identical to the engine before scoring grids existed. Build a coarser, offset or subregion grid with :meth:pyradmc.scoring.grid.ScoringGrid.rebin, or a depth-by-radial-shell pencil-beam kernel binning with :meth:pyradmc.scoring.cylinder.CylindricalScoringGrid.for_grid, from the same transport grid handed to this engine; deposits it does not cover are booked to TransportResult.energy_unscored, so emitted == deposited + unscored + escaped stays exact. Transport never sees this geometry: the streams, and hence the physics, are invariant to it — which is why a cylindrical binning is a readout choice and not a physics flag (AGENTS.md 2.10).

None
scoring_mode str

"dose_to_medium" (default) or "dose_to_water" — a scoring-OUTPUT selection (see :func:_deposit_weight_for and :mod:pyradmc.scoring.dose_to_water): transport is identical, only the per-deposit tally weighting differs, and the energy books stay physical in both modes. Requires transport_electrons=True.

'dose_to_medium'
deposit_resolution_cm float | None

Longest piece a half-substep's continuous energy loss is filed as, in cm. None (default) files it as one point deposit at the half-step midpoint — byte-identical to every result produced before this existed. A value splits the half-step into equal pieces no longer than it, depositing an equal share at each piece's midpoint.

Set it when scoring below the transport voxel scale. There, the midpoint deposit prints the voxel lattice onto the dose: substeps are capped at voxel faces, so their midpoints pile at voxel centres and the sub-voxel profile becomes a tent. A natural value is the finest bin the scorer resolves — see :attr:pyradmc.scoring.cylinder.CylindricalScoringGrid.finest_resolution_cm and :attr:pyradmc.scoring.grid.ScoringGrid.finest_resolution_cm. Leave it None when scoring at voxel resolution, where it buys nothing and costs time.

It moves no energy and changes no trajectory or random stream — only where a deposit is filed — so the energy books and the transported histories are identical either way.

None
step_energy_fraction float | None

Maximum fraction of CSDA range per electron substep; None (default) resolves to the selected msc_model's validated fraction (:func:~pyradmc.transport.electron.default_step_energy_fraction — 0.20 for the shipped "gs" configuration, 0.05 for the "gaussian" instrument). A measurement instrument for substep resolution studies; changing a default is a maintainer decision gated on the validation tier. msc_model semantics live on :func:~pyradmc.transport.electron.electron_steps.

None
progress ProgressCallback | None

Optional callback invoked with a :class:~pyradmc.progress.ProgressEvent once per completed batch (n_batches ticks total, each covering n_histories / n_batches histories). See :mod:pyradmc.progress — the same tick cadence as :meth:~pyradmc.backends.warp.engine.WarpEngine.run, so a callback written against one backend behaves identically against the other.

None
concurrent_batches int

Scheduling hint shared with the Warp API. The single-history reference oracle is deliberately sequential, so any positive value is accepted and has no effect.

1
Source code in pyradmc/backends/ref/engine.py
def run(
    self,
    source: Source,
    n_histories: int,
    n_batches: int,
    seed: int,
    pcut: float = PCUT_MEV,
    ecut: float = ECUT_MEV,
    transport_electrons: bool = True,
    primary_kind: str = "photon",
    scoring_grid: ScoringGeometry | None = None,
    scoring_mode: str = "dose_to_medium",
    step_energy_fraction: float | None = None,
    deposit_resolution_cm: float | None = None,
    msc_model: str = "gs",
    progress: ProgressCallback | None = None,
    concurrent_batches: int = 1,
) -> TransportResult:
    """Transport ``n_histories`` primaries in ``n_batches`` equal batches.

    Parameters
    ----------
    source
        Primary source; its geometry is particle-agnostic (see ``primary_kind``).
    n_histories
        Total primaries; must be divisible by ``n_batches`` so every batch mean
        carries equal statistical weight.
    n_batches
        Batches for the sigma estimate (AGENTS.md section 2.4).
    seed
        Global seed; history ``i`` uses the stream ``(seed, i)``, so the result
        is bit-reproducible for a given target and seed regardless of batching.
    pcut, ecut
        Photon and electron cutoffs in MeV. Accuracy-defining (AGENTS.md
        section 2.8); the defaults are the project-wide values and changing one
        in a call is a visible, greppable decision.
    transport_electrons
        False selects the KERMA approximation (charged secondaries
        deposit at their creation voxel) — the explicit option docs/decisions.md
        keeps for photon-only physics tests.
    primary_kind
        ``"photon"`` (default) or ``"electron"``: the fallback kind for sources
        whose emitted :class:`~pyradmc.geometry.source.Primary` leaves ``kind``
        unset (the monoenergetic beam sources). A phase-space source overrides
        it per record, so this argument is ignored for that source. The electron
        option exists for validating electron transport against ranges; electron
        *beams* as a clinical modality remain out of scope (AGENTS.md 6).
    scoring_grid
        Scoring geometry to accumulate dose on (decoupled scoring). ``None``
        (default) scores on the transport grid — byte-identical to the
        engine before scoring grids existed. Build a coarser, offset or
        subregion grid with :meth:`pyradmc.scoring.grid.ScoringGrid.rebin`, or
        a depth-by-radial-shell pencil-beam kernel binning with
        :meth:`pyradmc.scoring.cylinder.CylindricalScoringGrid.for_grid`,
        **from the same transport grid handed to this engine**; deposits it
        does not cover are booked to ``TransportResult.energy_unscored``, so
        ``emitted == deposited + unscored + escaped`` stays exact. Transport
        never sees this geometry: the streams, and hence the physics, are
        invariant to it — which is why a cylindrical binning is a readout
        choice and not a physics flag (AGENTS.md 2.10).
    scoring_mode
        ``"dose_to_medium"`` (default) or ``"dose_to_water"`` — a
        scoring-OUTPUT selection (see :func:`_deposit_weight_for` and
        :mod:`pyradmc.scoring.dose_to_water`): transport is identical, only
        the per-deposit tally weighting differs, and the energy books stay
        physical in both modes. Requires ``transport_electrons=True``.
    deposit_resolution_cm
        Longest piece a half-substep's continuous energy loss is filed as, in
        cm. ``None`` (default) files it as one point deposit at the half-step
        midpoint — byte-identical to every result produced before this
        existed. A value splits the half-step into equal pieces no longer than
        it, depositing an equal share at each piece's midpoint.

        Set it when scoring **below the transport voxel scale**. There, the
        midpoint deposit prints the voxel lattice onto the dose: substeps are
        capped at voxel faces, so their midpoints pile at voxel centres and
        the sub-voxel profile becomes a tent. A natural value is the finest
        bin the scorer resolves — see
        :attr:`pyradmc.scoring.cylinder.CylindricalScoringGrid.finest_resolution_cm`
        and :attr:`pyradmc.scoring.grid.ScoringGrid.finest_resolution_cm`.
        Leave it ``None`` when scoring at voxel resolution, where it buys
        nothing and costs time.

        It moves no energy and changes no trajectory or random stream — only
        where a deposit is filed — so the energy books and the transported
        histories are identical either way.
    step_energy_fraction
        Maximum fraction of CSDA range per electron substep; ``None``
        (default) resolves to the selected ``msc_model``'s validated
        fraction (:func:`~pyradmc.transport.electron.default_step_energy_fraction`
        — 0.20 for the shipped ``"gs"`` configuration, 0.05 for the
        ``"gaussian"`` instrument). A measurement instrument for substep
        resolution studies; changing a *default* is a maintainer decision
        gated on the validation tier. ``msc_model`` semantics live on
        :func:`~pyradmc.transport.electron.electron_steps`.
    progress
        Optional callback invoked with a :class:`~pyradmc.progress.ProgressEvent`
        once per completed batch (``n_batches`` ticks total, each covering
        ``n_histories / n_batches`` histories). See
        :mod:`pyradmc.progress` — the same tick cadence as
        :meth:`~pyradmc.backends.warp.engine.WarpEngine.run`, so a callback
        written against one backend behaves identically against the other.
    concurrent_batches
        Scheduling hint shared with the Warp API. The single-history reference
        oracle is deliberately sequential, so any positive value is accepted
        and has no effect.
    """
    if n_histories < 1:
        raise ValueError(f"need at least one history, got {n_histories}")
    if concurrent_batches < 1:
        raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
    if n_histories % n_batches != 0:
        raise ValueError(
            f"n_histories={n_histories} not divisible by n_batches={n_batches}; "
            "unequal batches would weight batch means inconsistently"
        )
    if primary_kind not in ("photon", "electron"):
        raise ValueError(f"unknown primary_kind {primary_kind!r}")
    deposit_weight = _deposit_weight_for(
        scoring_mode, self.cross_sections, ecut, transport_electrons
    )

    scorer = BatchedDoseScorer(
        scoring_grid if scoring_grid is not None else self.grid, n_batches
    )
    per_batch = n_histories // n_batches
    energy_emitted = 0.0
    energy_escaped = 0.0
    emitter = ProgressEmitter(progress, n_histories)

    history = 0
    for _ in range(n_batches):
        for _ in range(per_batch):
            state = self.rng.init_state(seed, history)
            history += 1
            primary = source.emit(state)
            kind_name = primary.kind if primary.kind is not None else primary_kind
            kind = _KIND_TO_PARTICLE[kind_name]
            # A positron primary will annihilate at rest, injecting 2*m_e c^2 of
            # photons from rest mass that its kinetic energy does not account for.
            # (For a photon that pair-produces, that 1.022 MeV is already inside
            # the photon's energy; a positron primary brings it as rest mass.)
            # Count it so the emitted = deposited + escaped ledger stays exact.
            rest_mass = 2.0 * ELECTRON_MASS_MEV if kind == POSITRON else 0.0
            energy_emitted += primary.weight * (primary.energy + rest_mass)
            energy_escaped += transport_history(
                kind,
                primary.energy,
                primary.x,
                primary.y,
                primary.z,
                primary.ux,
                primary.uy,
                primary.uz,
                self.grid,
                self.cross_sections,
                state,
                scorer.deposit_at,
                pcut,
                ecut,
                transport_electrons,
                weight=primary.weight,
                deposit_weight=deposit_weight,
                step_energy_fraction=step_energy_fraction,
                deposit_resolution_cm=deposit_resolution_cm,
                msc_model=msc_model,
            )
        scorer.end_batch(per_batch)
        emitter.tick(per_batch)

    dose = scorer.finalize()
    return TransportResult(
        dose=dose.dose,
        dose_sigma=dose.dose_sigma,
        energy_emitted=energy_emitted,
        energy_deposited=dose.energy_deposited,
        energy_escaped=energy_escaped,
        energy_unscored=dose.energy_unscored,
        n_histories=n_histories,
        n_batches=n_batches,
        scoring_mode=scoring_mode,
        provenance=self._provenance(
            seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
        ),
    )

run_dij

run_dij(source: BeamletSource, n_histories_per_beamlet: int, n_batches: int, seed: int, pcut: float = PCUT_MEV, ecut: float = ECUT_MEV, transport_electrons: bool = True, truncation: float = DIJ_TRUNCATION_RELATIVE, correlated: bool = True, scoring_grid: ScoringGrid | None = None, scoring_mode: str = 'dose_to_medium', step_energy_fraction: float | None = None, deposit_resolution_cm: float | None = None, msc_model: str = 'gs', progress: ProgressCallback | None = None) -> DijResult

Compute the beamlet-resolved dose influence matrix over the lattice.

History-to-beamlet mapping — the project-wide convention every backend follows: history h feeds beamlet j = h // n_histories_per_beamlet, and within a beamlet, batch b owns the contiguous slice of n_histories_per_beamlet / n_batches histories starting at j * n_histories_per_beamlet + b * (that slice length). Streams are pure functions of (seed, h), so the Dij is bit-reproducible on one target regardless of how a backend schedules the transport, and a 1x1 lattice reproduces the open-field :meth:run bit for bit (test-pinned).

Correlated sampling changes the stream key only: with correlated=True, the history at within-beamlet index rw = h - j * n_histories_per_beamlet draws the stream (seed, rw) instead of (seed, h), so corresponding histories of every beamlet replay the same random sequence — same within-bixel entry offset, same interaction sequence — and only the beamlet's position differs. Beamlet assignment, batching, scoring and the energy books are untouched, and on a 1x1 lattice rw == h, so the open-field anchor above holds in both modes (test-pinned).

Every deposit of a history's whole secondary family scores into its beamlet's column: the columns partition the open-field dose exactly. Column doses are per emitted history of that beamlet, MeV/g.

Parameters mirror :meth:run; the two Dij-specific ones:

Parameters:

Name Type Description Default
n_histories_per_beamlet int

Histories per beamlet (equal by design — stratified, not sampled); must be divisible by n_batches.

required
truncation float

Per-column relative truncation threshold. Accuracy-defining (AGENTS.md 2.8): the default is :data:pyradmc.DIJ_TRUNCATION_RELATIVE and a different value in a call is a visible, greppable decision.

DIJ_TRUNCATION_RELATIVE
correlated bool

Key streams on the within-beamlet index so columns share random sequences (correlated sampling). This is the shipped configuration (default True): the noise/bias study (examples/noise_bias_study.py) found it halves the renormalized plan-dose error at matched per-beamlet sigma, in water and through a heterogeneity, and never worse on raw plan quality. correlated=False selects the independent mapping and exists only as a test instrument (AGENTS.md 2.10): it isolates the column independence the fluence-sum identity's quadrature sigma needs. A correlated Dij's columns are statistically dependent — per-column sigmas stay valid, but never combine sigmas across columns in quadrature. The result records the mode in DijResult.correlated.

True
scoring_grid ScoringGrid | None

Dose grid the Dij columns live on; semantics as in :meth:run. The memory lever for plan-scale problems: the dense per-group buffers and the sparse Dij all scale with the scoring voxel count, so a coarser dose grid shrinks them cubically while transport keeps the full CT resolution.

None
scoring_mode str

Tally weighting of the columns, as in :meth:run; recorded in DijResult.scoring_mode.

'dose_to_medium'
deposit_resolution_cm float | None

Sub-substep deposit resolution, as in :meth:run. Relevant only when scoring_grid is finer than the transport grid, which for a Dij is unusual — the dose grid is normally the memory lever and therefore coarser, where the default None is both correct and cheaper.

None
progress ProgressCallback | None

Optional callback, as in :meth:run. Ticks once per completed batch (n_batches ticks total), each covering n_beamlets * n_histories_per_beamlet / n_batches histories — this engine iterates batch-outer, beamlet-inner, so a batch spans every beamlet. :meth:~pyradmc.backends.warp.engine.WarpEngine.run_dij ticks on a different axis (per beamlet group, not per batch): both reach the same total, but tick count and spacing differ between backends. Treat histories_done / histories_total as the portable signal (see :mod:pyradmc.progress).

None
Source code in pyradmc/backends/ref/engine.py
def run_dij(
    self,
    source: BeamletSource,
    n_histories_per_beamlet: int,
    n_batches: int,
    seed: int,
    pcut: float = PCUT_MEV,
    ecut: float = ECUT_MEV,
    transport_electrons: bool = True,
    truncation: float = DIJ_TRUNCATION_RELATIVE,
    correlated: bool = True,
    scoring_grid: ScoringGrid | None = None,
    scoring_mode: str = "dose_to_medium",
    step_energy_fraction: float | None = None,
    deposit_resolution_cm: float | None = None,
    msc_model: str = "gs",
    progress: ProgressCallback | None = None,
) -> DijResult:
    """Compute the beamlet-resolved dose influence matrix over the lattice.

    History-to-beamlet mapping — the project-wide convention every backend
    follows: history ``h`` feeds beamlet ``j = h // n_histories_per_beamlet``,
    and within a beamlet, batch ``b`` owns the contiguous slice of
    ``n_histories_per_beamlet / n_batches`` histories starting at
    ``j * n_histories_per_beamlet + b * (that slice length)``. Streams are pure
    functions of ``(seed, h)``, so the Dij is bit-reproducible on one target
    regardless of how a backend schedules the transport, and a 1x1 lattice
    reproduces the open-field :meth:`run` bit for bit (test-pinned).

    Correlated sampling changes the *stream key* only: with
    ``correlated=True``, the history at within-beamlet index
    ``rw = h - j * n_histories_per_beamlet`` draws the stream ``(seed, rw)``
    instead of ``(seed, h)``, so corresponding histories of every beamlet
    replay the same random sequence — same within-bixel entry offset, same
    interaction sequence — and only the beamlet's position differs. Beamlet
    assignment, batching, scoring and the energy books are untouched, and on
    a 1x1 lattice ``rw == h``, so the open-field anchor above holds in both
    modes (test-pinned).

    Every deposit of a history's whole secondary family scores into its
    beamlet's column: the columns partition the open-field dose exactly.
    Column doses are per emitted history *of that beamlet*, MeV/g.

    Parameters mirror :meth:`run`; the two Dij-specific ones:

    Parameters
    ----------
    n_histories_per_beamlet
        Histories per beamlet (equal by design — stratified, not sampled);
        must be divisible by ``n_batches``.
    truncation
        Per-column relative truncation threshold. Accuracy-defining
        (AGENTS.md 2.8): the default is :data:`pyradmc.DIJ_TRUNCATION_RELATIVE`
        and a different value in a call is a visible, greppable decision.
    correlated
        Key streams on the within-beamlet index so columns share random
        sequences (correlated sampling). **This is the shipped
        configuration** (default True): the noise/bias study
        (``examples/noise_bias_study.py``) found it halves the
        renormalized plan-dose error at matched per-beamlet sigma, in water
        and through a heterogeneity, and never worse on raw plan quality.
        ``correlated=False`` selects the independent mapping and exists
        only as a **test instrument** (AGENTS.md 2.10): it isolates the
        column independence the fluence-sum identity's quadrature sigma
        needs. A correlated Dij's columns are statistically dependent —
        per-column sigmas stay valid, but never combine sigmas across
        columns in quadrature. The result records the mode in
        ``DijResult.correlated``.
    scoring_grid
        Dose grid the Dij columns live on; semantics as in :meth:`run`. The
        memory lever for plan-scale problems: the dense per-group buffers and
        the sparse Dij all scale with the *scoring* voxel count, so a coarser
        dose grid shrinks them cubically while transport keeps the full CT
        resolution.
    scoring_mode
        Tally weighting of the columns, as in :meth:`run`; recorded in
        ``DijResult.scoring_mode``.
    deposit_resolution_cm
        Sub-substep deposit resolution, as in :meth:`run`. Relevant only when
        ``scoring_grid`` is finer than the transport grid, which for a Dij is
        unusual — the dose grid is normally the memory lever and therefore
        coarser, where the default ``None`` is both correct and cheaper.
    progress
        Optional callback, as in :meth:`run`. Ticks once per completed batch
        (``n_batches`` ticks total), each covering
        ``n_beamlets * n_histories_per_beamlet / n_batches`` histories — this
        engine iterates batch-outer, beamlet-inner, so a batch spans every
        beamlet. :meth:`~pyradmc.backends.warp.engine.WarpEngine.run_dij`
        ticks on a different axis (per beamlet group, not per batch): both
        reach the same total, but tick count and spacing differ between
        backends. Treat ``histories_done / histories_total`` as the portable
        signal (see :mod:`pyradmc.progress`).
    """
    if n_histories_per_beamlet < 1:
        raise ValueError(
            f"need at least one history per beamlet, got {n_histories_per_beamlet}"
        )
    if n_histories_per_beamlet % n_batches != 0:
        raise ValueError(
            f"n_histories_per_beamlet={n_histories_per_beamlet} not divisible by "
            f"n_batches={n_batches}; unequal batches would weight batch means inconsistently"
        )

    deposit_weight = _deposit_weight_for(
        scoring_mode, self.cross_sections, ecut, transport_electrons
    )
    n_beamlets = source.n_beamlets
    per_batch = n_histories_per_beamlet // n_batches
    scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
    scorer = BatchedBeamletScorer(scoring, n_batches, n_beamlets)
    energy_emitted = 0.0
    energy_escaped = 0.0
    emitter = ProgressEmitter(progress, n_beamlets * n_histories_per_beamlet)

    for batch in range(n_batches):
        for beamlet in range(n_beamlets):
            deposit = partial(scorer.deposit_at, beamlet)
            for r in range(per_batch):
                rw = batch * per_batch + r
                h = beamlet * n_histories_per_beamlet + rw
                state = self.rng.init_state(seed, rw if correlated else h)
                primary = source.emit(beamlet, state)
                energy_emitted += primary.weight * primary.energy
                energy_escaped += transport_history(
                    PHOTON,
                    primary.energy,
                    primary.x,
                    primary.y,
                    primary.z,
                    primary.ux,
                    primary.uy,
                    primary.uz,
                    self.grid,
                    self.cross_sections,
                    state,
                    deposit,
                    pcut,
                    ecut,
                    transport_electrons,
                    weight=primary.weight,
                    deposit_weight=deposit_weight,
                    step_energy_fraction=step_energy_fraction,
                    deposit_resolution_cm=deposit_resolution_cm,
                    msc_model=msc_model,
                )
        scorer.end_batch(per_batch)
        emitter.tick(n_beamlets * per_batch)

    block = scorer.finalize()
    assembler = DijAssembler(
        grid_shape=scoring.shape,
        n_beamlets=n_beamlets,
        n_histories_per_beamlet=n_histories_per_beamlet,
        n_batches=n_batches,
        truncation=truncation,
        correlated=correlated,
        scoring_mode=scoring_mode,
        provenance=self._provenance(
            seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
        ),
    )
    assembler.add_block(0, block.dose, block.sigma)
    return assembler.finalize(
        energy_emitted=energy_emitted,
        energy_deposited=block.energy_deposited,
        energy_escaped=energy_escaped,
        energy_unscored=block.energy_unscored,
    )

pyradmc.backends.warp.engine.WarpEngine dataclass

Dual-target production engine over a voxel grid.

Parameters:

Name Type Description Default
grid VoxelGrid

As in the reference engine.

required
cross_sections VoxelGrid

As in the reference engine.

required
device str

Warp device string: "cpu" or "cuda:N".

'cpu'
chunk_size int | None

Histories transported concurrently. Statistically and bit-wise inert (test-pinned); it only trades memory against launch count. Default None auto-sizes it per device from the SM count and reported-free memory (:func:_auto_chunk_size); an explicit integer is used as given. The transport is latency-bound, so this is the knob that decides how much independent work is resident: measured on a 36-SM 4070, the historic fixed 32768 ran a 6 MV open field at 0.55x the auto size's throughput.

None
queue_factor int

Queue capacity per chunk history. Overflow raises rather than dropping secondaries.

16
Source code in pyradmc/backends/warp/engine.py
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
@dataclass(frozen=True)
class WarpEngine:
    """Dual-target production engine over a voxel grid.

    Parameters
    ----------
    grid, cross_sections
        As in the reference engine.
    device
        Warp device string: ``"cpu"`` or ``"cuda:N"``.
    chunk_size
        Histories transported concurrently. Statistically and bit-wise inert
        (test-pinned); it only trades memory against launch count. Default
        ``None`` auto-sizes it per device from the SM count and reported-free
        memory (:func:`_auto_chunk_size`); an explicit integer is used as given.
        The transport is latency-bound, so this is the knob that decides how much
        independent work is resident: measured on a 36-SM 4070, the historic
        fixed 32768 ran a 6 MV open field at **0.55x** the auto size's throughput.
    queue_factor
        Queue capacity per chunk history. Overflow raises rather than dropping
        secondaries.
    """

    grid: VoxelGrid
    cross_sections: CrossSectionSource
    device: str = "cpu"
    chunk_size: int | None = None
    queue_factor: int = 16
    _auto_chunk: dict = field(default_factory=dict, init=False, repr=False, compare=False)
    # Host-table cache: build_tables flattens the source by looping the Python query
    # API over every grid node — seconds of fixed overhead for a tabulated source —
    # while its result is frozen after construction, so one build per
    # (ecut, pcut, e_max) serves every run and every device shard. Only the host
    # flatten is cached; the device upload stays per call. The lock keeps concurrent
    # Dij shards (one host thread per device) from building the same key twice.
    _table_cache: dict = field(default_factory=dict, init=False, repr=False, compare=False)
    _table_cache_lock: threading.Lock = field(
        default_factory=threading.Lock, init=False, repr=False, compare=False
    )

    def _provenance(
        self,
        seed: int,
        pcut: float,
        ecut: float,
        msc_model: str,
        step_energy_fraction: float,
        deposit_resolution_cm: float | None,
        devices: Sequence[str] | None = None,
    ) -> RunProvenance:
        """Record the configuration this run actually used.

        ``devices`` is the Dij sharding set: a matrix assembled across several cards
        did not come from one device, and recording only ``self.device`` would claim
        a reproducibility that does not hold. The joined string says what ran.
        """
        return RunProvenance(
            version=__version__,
            backend="warp",
            device="+".join(devices) if devices else self.device,
            seed=seed,
            pcut_mev=pcut,
            ecut_mev=ecut,
            msc_model=msc_model,
            step_energy_fraction=step_energy_fraction,
            cross_sections=self.cross_sections.provenance,
            deposit_resolution_cm=deposit_resolution_cm,
        )

    def _chunk_histories(self, device: str | None = None) -> int:
        """Resolve ``chunk_size``, auto-sizing per device when it was left unset.

        Memoized per device string: a ``devices=[...]`` Dij sizes each shard from
        the device that will run it, so a mixed set does not inherit one card's
        width. Bit-inert, so shards may legitimately differ here.
        """
        if self.chunk_size is not None:
            return self.chunk_size
        key = device or self.device
        cached = self._auto_chunk.get(key)
        if cached is None:
            cached = _auto_chunk_size(key, self.queue_factor)
            self._auto_chunk[key] = cached
        return int(cached)

    def run(
        self,
        source: Source,
        n_histories: int,
        n_batches: int,
        seed: int,
        pcut: float = PCUT_MEV,
        ecut: float = ECUT_MEV,
        transport_electrons: bool = True,
        primary_kind: str = "photon",
        scoring_grid: ScoringGeometry | None = None,
        scoring_mode: str = "dose_to_medium",
        step_energy_fraction: float | None = None,
        deposit_resolution_cm: float | None = None,
        msc_model: str = "gs",
        progress: ProgressCallback | None = None,
        concurrent_batches: int = 1,
    ) -> TransportResult:
        """Transport ``n_histories`` primaries; same contract as the reference engine.

        See :meth:`pyradmc.backends.ref.engine.ReferenceEngine.run` for parameter
        semantics — the two signatures are deliberately identical (``scoring_grid``
        included: the scoring geometry deposits accumulate on — a rectilinear
        :class:`~pyradmc.scoring.grid.ScoringGrid`, default the transport grid, or a
        :class:`~pyradmc.scoring.cylinder.CylindricalScoringGrid` for a pencil-beam
        kernel — with off-geometry deposits booked to ``energy_unscored``; and ``scoring_mode``:
        dose-to-water weights each deposit in-kernel by the stopping-power ratio
        from the flattened tables while the books stay physical; ``progress``: one
        tick per completed batch, the identical cadence to the reference engine —
        see :mod:`pyradmc.progress`). A source with an in-kernel generator is
        generated on-device: the built-in mono beams from analytic parameters (all
        of one ``primary_kind``) and the exact
        :class:`~pyradmc.geometry.source.SpectralBeamSource` type from its uploaded
        spectrum tables (photons; ``primary_kind`` ignored). Any other source (a
        phase space, a spectral *subclass*, or a user
        :class:`~pyradmc.geometry.source.Source`) is transported by host-sampling
        each chunk via ``sample_batch`` and seeding the photon and electron queues
        by the per-record kind; ``primary_kind`` is then ignored and
        ``energy_emitted`` is booked from the sampled records.

        ``concurrent_batches`` overlaps whole statistical batches on independent
        CUDA streams, with private queues, RNG slots, and fixed-point dose maps.
        The float64 batch-dose fold remains serialized in batch order, so changing
        the lane count is bitwise inert on one device. CPU accepts the same option
        but runs sequentially. Each lane requires another queue set and dose map;
        memory use therefore grows approximately linearly with the lane count.

        ``msc_model`` selects the multiple-scattering law exactly as in the
        reference loop (see
        :func:`pyradmc.transport.electron.electron_steps`): ``"gs"`` — the
        shipped default — samples Goudsmit-Saunderson deflections from an
        eagerly precomputed table grid (built host-side on the first GS run
        for this ``(ecut, e_max)``, persisted to the user cache, uploaded per
        run) and does not apply the Gaussian-validity angular cap; the
        ``"gaussian"`` hinge survives as the paired-comparison test
        instrument. ``step_energy_fraction=None`` resolves to the selected
        model's validated fraction.
        """
        if n_histories < 1:
            raise ValueError(f"need at least one history, got {n_histories}")
        if concurrent_batches < 1:
            raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
        if n_histories % n_batches != 0:
            raise ValueError(
                f"n_histories={n_histories} not divisible by n_batches={n_batches}; "
                "unequal batches would weight batch means inconsistently"
            )
        from pyradmc.backends.warp.presolve import DevicePhaseSpace

        in_kernel = isinstance(source, PencilBeamSource | ParallelBeamSource)
        is_device_ps = isinstance(source, DevicePhaseSpace)
        # Exact type, not isinstance: a subclass may override emit/sample_batch, and
        # the built-in generator would silently bypass the override; a subclass keeps
        # the host pre-sampling route (test-pinned).
        is_spectral = type(source) is SpectralBeamSource
        is_spectral_mask = (
            type(source) is TransmissionMaskSource and type(source.inner) is SpectralBeamSource
        )
        if is_device_ps and source.device != self.device:
            raise ValueError(
                f"the device pre-solve buffer is on {source.device!r} but this engine "
                f"runs on {self.device!r}; pre-solve on the engine's device"
            )
        if in_kernel:
            if primary_kind not in ("photon", "electron"):
                raise ValueError(f"unknown primary_kind {primary_kind!r}")
            kind = PHOTON if primary_kind == "photon" else ELECTRON

        dose_to_water = 1 if validate_scoring_mode(scoring_mode, transport_electrons) else 0
        msc_model_gs = _validate_msc_model(msc_model)
        if step_energy_fraction is None:
            step_energy_fraction = default_step_energy_fraction(msc_model)
        if deposit_resolution_cm is not None and deposit_resolution_cm <= 0.0:
            raise ValueError(f"deposit resolution must be positive, got {deposit_resolution_cm}")
        # One float travels to the kernels; non-positive is the single-midpoint
        # sentinel, matching pyradmc.transport.electron.substep_pieces.
        deposit_resolution = 0.0 if deposit_resolution_cm is None else deposit_resolution_cm

        device = self.device
        gi, density, material = self._upload_grid(device)
        scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
        si = _scoring_info(scoring, device)
        table_energy = source.max_energy
        tab = self._upload_tables(table_energy, pcut, ecut, device, with_gs=msc_model_gs == 1)

        chunk = min(self._chunk_histories(device), n_histories)
        capacity = chunk * self.queue_factor
        lanes = min(concurrent_batches, n_batches) if wp.get_device(device).is_cuda else 1
        lane_resources = [
            _LaneResources(
                stream=wp.Stream(device) if lanes > 1 else None,
                staging=(
                    wp.zeros(1, dtype=wp.int32, device="cpu", pinned=True) if lanes > 1 else None
                ),
                queues=[_upload_queue(capacity, device) for _ in range(4)],
                slots=wp.zeros(capacity, dtype=wp.uint32, device=device),
                edep=wp.zeros(scoring.n_voxels, dtype=wp.int64, device=device),
            )
            for _ in range(lanes)
        ]
        queues = lane_resources[0].queues
        slots = lane_resources[0].slots
        edep = lane_resources[0].edep
        fold_stream = wp.Stream(device) if lanes > 1 else None
        escaped = wp.zeros(1, dtype=wp.int64, device=device)
        unscored = wp.zeros(1, dtype=wp.int64, device=device)
        deposited = wp.zeros(1, dtype=wp.int64, device=device)
        violations = wp.zeros(1, dtype=wp.int32, device=device)

        # Advanced route: a source exposing a warp_sampler is generated in-kernel by a
        # wrapper kernel (built once per sampler), which books emitted weight-energy
        # into this cumulative counter (mono beams book analytically; a pre-sampled
        # source books per chunk).
        generator = None
        emitted = None
        wraps = not in_kernel and not is_device_ps and not is_spectral and not is_spectral_mask
        if wraps and source.warp_sampler is not None:
            generator = kernels.make_generator_kernel(source.warp_sampler)
            emitted = wp.zeros(1, dtype=wp.int64, device=device)
        if is_device_ps:
            # The seeding kernel books emitted weight-energy into this counter,
            # like the wrapped-generator route, read once after the batches.
            emitted = wp.zeros(1, dtype=wp.int64, device=device)
        spectral_tables = None
        mask_table = None
        if is_spectral or is_spectral_mask:
            # Built-in in-kernel generation for the exact spectral type: the spectrum
            # inversion tables upload once per run and each chunk samples on-device
            # (the host pre-sampling was measured wall-dominant on CT-grade runs).
            # Emitted energy is booked per primary, like the wrapped-generator route.
            spectral_source = source.inner if is_spectral_mask else source
            spectrum = spectral_source.spectrum
            spectral_tables = (
                wp.array(spectrum.edges.astype(np.float32), dtype=float, device=device),
                wp.array(spectrum.cdf.astype(np.float32), dtype=float, device=device),
            )
            emitted = wp.zeros(1, dtype=wp.int64, device=device)
            if is_spectral_mask:
                mask_table = wp.array(
                    source.mask.reshape(-1).astype(np.float32), dtype=float, device=device
                )

        n_voxels = scoring.n_voxels
        voxel_mass = wp.array(scoring.voxel_mass.reshape(n_voxels), dtype=wp.float64, device=device)
        # Dose finalize on the device: per-voxel batch sums of dose and dose^2, folded
        # in batch order so the open-field reduction matches a 1x1 Dij column bitwise
        # on one device (kernels.accumulate_run_batch). Only the reduced maps read
        # back, not the dense per-batch fixed-point buffer.
        s1 = wp.zeros(n_voxels, dtype=wp.float64, device=device)
        s2 = wp.zeros(n_voxels, dtype=wp.float64, device=device)
        dep_total = wp.zeros(1, dtype=wp.int64, device=device)  # dose-to-medium quanta
        per_batch = n_histories // n_batches
        energy_escaped = 0.0
        energy_unscored = 0.0
        energy_deposited = 0.0  # dose-to-water physical book (summed per-batch counter)
        energy_emitted = 0.0
        escaped_quanta = 0
        unscored_quanta = 0
        deposited_quanta = 0
        emitter = ProgressEmitter(progress, n_histories)

        if lanes > 1:
            # Shared counters use integer atomics and therefore need only one clear
            # for the whole concurrent run. Each lane owns the state whose writes
            # are not commutative (queues, RNG slots, and its batch dose map).
            escaped.zero_()
            unscored.zero_()
            deposited.zero_()
            violations.zero_()
            energy_emitted += self._run_forward_batch_lanes(
                lane_resources,
                fold_stream,
                n_batches,
                per_batch,
                chunk,
                source=source,
                kind=kind if in_kernel else None,
                in_kernel=in_kernel,
                is_device_ps=is_device_ps,
                is_spectral=is_spectral,
                is_spectral_mask=is_spectral_mask,
                generator=generator,
                spectral_tables=spectral_tables,
                mask_table=mask_table,
                seed=seed,
                gi=gi,
                si=si,
                density=density,
                material=material,
                tab=tab,
                escaped=escaped,
                unscored=unscored,
                deposited=deposited,
                violations=violations,
                pcut=pcut,
                ecut=ecut,
                step_energy_fraction=step_energy_fraction,
                deposit_resolution=deposit_resolution,
                msc_model_gs=msc_model_gs,
                transport_electrons=transport_electrons,
                dose_to_water=dose_to_water,
                emitted=emitted,
                device=device,
                voxel_mass=voxel_mass,
                n_voxels=n_voxels,
                s1=s1,
                s2=s2,
                dep_total=dep_total,
                emitter=emitter,
            )
            wp.synchronize_device(device)
            if int(violations.numpy()[0]) != 0:
                raise RuntimeError(
                    "Woodcock majorant violated in the kernel despite table headroom. "
                    "The geometry contains material or density the majorant "
                    "declaration did not cover."
                )
            escaped_quanta = int(escaped.numpy()[0])
            unscored_quanta = int(unscored.numpy()[0])
            if dose_to_water != 0:
                deposited_quanta = int(deposited.numpy()[0])

        history = 0
        for _ in range(n_batches if lanes == 1 else 0):
            edep.zero_()
            escaped.zero_()
            unscored.zero_()
            deposited.zero_()
            remaining = per_batch
            while remaining > 0:
                n_chunk = min(chunk, remaining)
                if in_kernel:
                    self._transport_chunk(
                        source,
                        kind,
                        seed,
                        history,
                        n_chunk,
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        queues,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        violations,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        transport_electrons,
                        dose_to_water,
                        device,
                    )
                elif is_device_ps:
                    self._transport_chunk_device_buffer(
                        source,
                        seed,
                        history,
                        n_chunk,
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        queues,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        violations,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        transport_electrons,
                        dose_to_water,
                        emitted,
                        device,
                    )
                elif is_spectral_mask:
                    self._transport_chunk_spectral_mask(
                        source,
                        spectral_tables,
                        mask_table,
                        seed,
                        history,
                        n_chunk,
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        queues,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        violations,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        transport_electrons,
                        dose_to_water,
                        emitted,
                        device,
                    )
                elif is_spectral:
                    self._transport_chunk_spectral(
                        source,
                        spectral_tables,
                        seed,
                        history,
                        n_chunk,
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        queues,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        violations,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        transport_electrons,
                        dose_to_water,
                        emitted,
                        device,
                    )
                elif generator is not None:
                    self._transport_chunk_wrapped(
                        generator,
                        seed,
                        history,
                        n_chunk,
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        queues,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        violations,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        transport_electrons,
                        dose_to_water,
                        emitted,
                        device,
                    )
                else:
                    energy_emitted += self._transport_chunk_presampled(
                        source,
                        seed,
                        history,
                        n_chunk,
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        queues,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        violations,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        transport_electrons,
                        dose_to_water,
                        device,
                    )
                history += n_chunk
                remaining -= n_chunk
            wp.synchronize_device(device)
            if int(violations.numpy()[0]) != 0:
                raise RuntimeError(
                    "Woodcock majorant violated in the kernel despite table headroom. "
                    "The geometry contains material or density the majorant "
                    "declaration did not cover."
                )
            # Fold this batch into the running dose sums on the device; the dense
            # fixed-point map never leaves the GPU. dep_total sums the physical
            # quanta (dose-to-medium book); dose-to-water reads its own counter.
            wp.launch(
                kernels.accumulate_run_batch,
                dim=n_voxels,
                inputs=[
                    edep,
                    voxel_mass,
                    float(per_batch),
                    float(ENERGY_QUANTUM_MEV),
                    s1,
                    s2,
                    dep_total,
                ],
                device=device,
            )
            escaped_quanta += int(escaped.numpy()[0])
            unscored_quanta += int(unscored.numpy()[0])
            if dose_to_water != 0:
                deposited_quanta += int(deposited.numpy()[0])
            emitter.tick(per_batch)

        # Convert the exact integer books once, after aggregation. Besides avoiding
        # batch-count-dependent float rounding, this matches the Dij energy fold and
        # keeps concurrent lane scheduling bitwise inert.
        energy_escaped = float(escaped_quanta) * ENERGY_QUANTUM_MEV
        energy_unscored = float(unscored_quanta) * ENERGY_QUANTUM_MEV
        if dose_to_water != 0:
            energy_deposited = float(deposited_quanta) * ENERGY_QUANTUM_MEV

        if in_kernel:
            # Mono beams: every primary is one unit-weight photon at the beam energy
            # (max_energy == energy), so the device need not report emitted energy.
            energy_emitted = n_histories * source.max_energy
        elif emitted is not None:
            energy_emitted = float(emitted.numpy()[0]) * ENERGY_QUANTUM_MEV

        mean_dev = wp.zeros(n_voxels, dtype=wp.float64, device=device)
        sigma_dev = wp.zeros(n_voxels, dtype=wp.float64, device=device)
        wp.launch(
            kernels.finalize_run,
            dim=n_voxels,
            inputs=[s1, s2, n_batches, mean_dev, sigma_dev],
            device=device,
        )
        wp.synchronize_device(device)
        if dose_to_water == 0:
            energy_deposited = float(dep_total.numpy()[0]) * ENERGY_QUANTUM_MEV
        return TransportResult(
            dose=mean_dev.numpy().reshape(scoring.shape),
            dose_sigma=sigma_dev.numpy().reshape(scoring.shape),
            energy_emitted=energy_emitted,
            energy_deposited=energy_deposited,
            energy_escaped=energy_escaped,
            energy_unscored=energy_unscored,
            n_histories=n_histories,
            n_batches=n_batches,
            scoring_mode=scoring_mode,
            provenance=self._provenance(
                seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
            ),
        )

    def run_dij(
        self,
        source: BeamletSource,
        n_histories_per_beamlet: int,
        n_batches: int,
        seed: int,
        pcut: float = PCUT_MEV,
        ecut: float = ECUT_MEV,
        transport_electrons: bool = True,
        truncation: float = DIJ_TRUNCATION_RELATIVE,
        correlated: bool = True,
        beamlet_group_size: int | None = None,
        scoring_grid: ScoringGrid | None = None,
        scoring_mode: str = "dose_to_medium",
        step_energy_fraction: float | None = None,
        deposit_resolution_cm: float | None = None,
        msc_model: str = "gs",
        devices: Sequence[str] | None = None,
        concurrent_batches: int = 1,
        progress: ProgressCallback | None = None,
    ) -> DijResult:
        """Compute the Dij over the lattice; same contract as the reference engine.

        See :meth:`pyradmc.backends.ref.engine.ReferenceEngine.run_dij` for the
        history-to-beamlet mapping and parameter semantics, ``correlated``
        (the shipped sampling configuration, default True; ``False`` is the
        test instrument) included — the signatures are deliberately identical
        up to the one scheduling knob:

        Parameters
        ----------
        beamlet_group_size
            Beamlets scored concurrently into one dense device buffer. Purely a
            memory/occupancy trade-off: streams are pure functions of
            ``(seed, history)`` and scoring is associative, so the result is
            bit-identical for any value (test-pinned), exactly like ``chunk_size``.

            Default ``None`` auto-sizes it from the *reported free* memory of the
            (each) device — the largest group whose dense maps fit half of it,
            clamped to ``[1, min(n_beamlets, 1024)]``, the minimum over a
            multi-device set, and 128 where free memory is unknown (cpu). An
            explicit integer is used exactly as given.

            This is the *only* dial for the dense device cost, which is
            ``group * n_voxels * 24`` bytes — one int64 quanta map plus the two
            float64 sums — and is **independent of** ``n_batches``: batches are
            streamed one drain at a time and folded into the sums, not stored
            along a device axis. It is also what sets the launch width, since a
            batch's block is ``group * n_histories_per_beamlet / n_batches``
            histories; raise it until that block covers ``chunk_size``, memory
            permitting. On a CT-resolution scoring grid the auto-size shrinks
            the group to fit (pass an explicit value to override) — or coarsen
            ``scoring_grid``, which shrinks the dense cost cubically.

        devices
            Devices to shard the beamlet groups over, one host thread each; default
            (``None``) runs everything on the engine's own ``device``. Groups are
            independent — no cross-device reduction — so this scales with the device
            count rather than trading anything away, and each device pays the full
            per-device footprint (geometry, tables, queues, and the dense
            ``group * n_voxels * 24`` maps) since nothing is shared.

            Scheduling is greedy from a shared ordered queue: a device pulls the
            next group the moment it is free, so an idle device always receives new
            work before any deeper concurrency (``concurrent_batches`` lanes) on a
            busy one, and a slow device in a mixed set self-limits to the groups it
            can finish instead of holding an equal share hostage.

            Reproducibility: every column is computed wholly on one device, so
            scheduling never changes *what* a device computes for a beamlet — only
            which device computes it (test-pinned). Over identical devices the Dij
            is therefore bit-identical however the pulls interleave. Over a
            *heterogeneous* set (e.g. ``["cuda:0", "cpu"]``) the column-to-device
            assignment is timing-dependent run to run — columns stay statistically
            equivalent, the cpu/cuda relationship AGENTS.md 2.3 defines, but the
            same run twice may place them differently; pass a single device where
            strict run-to-run bit reproducibility matters on mixed hardware. The
            energy books stay exact either way, being integer quanta summed in
            device-list order.
        concurrent_batches
            Batch lanes per CUDA device: up to this many batches of the current
            group transport concurrently, each lane on its own stream with its own
            queues, RNG slots, and quanta map. **Bitwise inert** (test-pinned): the
            float64 fold of batch sums is serialized in batch order across lanes,
            so any value reproduces the sequential Dij exactly — the knob only
            trades memory for overlap, like ``chunk_size``. Extra lanes cost their
            queues and one ``group * n_voxels`` int64 map each; a cpu device has no
            streams and ignores the setting. On this engine's development hardware
            (laptop RTX 4070) the drain gaps lanes can hide measured at 2-3% of
            Dij wall time — the knob exists for larger cards, where the balance
            may differ; measure before defaulting it on.

        scoring_grid
            Semantics as in :meth:`run`, and the memory lever here: the
            ``n_voxels`` above is the *scoring* voxel count, so a coarser dose
            grid shrinks the per-group device buffer and the sparse Dij cubically
            while transport keeps the full CT resolution.
        scoring_mode
            Weights the column tallies as in :meth:`run`; the energy books stay
            physical in either mode.
        msc_model
            As in :meth:`run`. The GS grid is built once, before any launch, under
            the same lock every device shard takes.
        progress
            Optional callback, as in :meth:`run` (see :mod:`pyradmc.progress`).
            Ticks once per **beamlet group** *completed* (``ceil(n_beamlets /
            beamlet_group_size)`` ticks total), each covering that group's full
            ``group_size * n_histories_per_beamlet`` histories across all of its
            batches. A tick marks the group finished — transported, reduced,
            truncated and read back — not merely transported, so ``rate_hz``
            describes the group it names and the last tick coincides with the
            method returning — a different axis from the reference engine's per-batch
            ticks, since this engine schedules groups off a shared queue rather
            than iterating batches outermost. Both reach the same total; treat
            ``histories_done / histories_total`` as the portable signal, not tick
            count or spacing. One :class:`~pyradmc.progress.ProgressEmitter` is
            shared across every device shard and, within a shard, is unaffected
            by ``concurrent_batches`` (only the shard's outer per-group point
            ticks): the emitter's own lock serializes ticks arriving from
            multiple device threads, so the callback is thread-safe without any
            extra care on the caller's part.
        """
        if n_histories_per_beamlet < 1:
            raise ValueError(
                f"need at least one history per beamlet, got {n_histories_per_beamlet}"
            )
        if n_histories_per_beamlet % n_batches != 0:
            raise ValueError(
                f"n_histories_per_beamlet={n_histories_per_beamlet} not divisible by "
                f"n_batches={n_batches}; unequal batches would weight batch means inconsistently"
            )
        if beamlet_group_size is not None and beamlet_group_size < 1:
            raise ValueError(f"need a positive beamlet group size, got {beamlet_group_size}")
        if concurrent_batches < 1:
            raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
        dose_to_water = 1 if validate_scoring_mode(scoring_mode, transport_electrons) else 0
        msc_model_gs = _validate_msc_model(msc_model)
        if step_energy_fraction is None:
            step_energy_fraction = default_step_energy_fraction(msc_model)
        if deposit_resolution_cm is not None and deposit_resolution_cm <= 0.0:
            raise ValueError(f"deposit resolution must be positive, got {deposit_resolution_cm}")
        # One float travels to the kernels; non-positive is the single-midpoint
        # sentinel, matching pyradmc.transport.electron.substep_pieces.
        deposit_resolution = 0.0 if deposit_resolution_cm is None else deposit_resolution_cm

        scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
        n_beamlets = source.n_beamlets
        per_batch = n_histories_per_beamlet // n_batches

        device_list = [self.device] if devices is None else list(dict.fromkeys(devices))
        if not device_list:
            raise ValueError("devices must name at least one device")

        if beamlet_group_size is None:
            # One shared size for every device: group_starts partitions the beamlet
            # axis once, so a multi-device set takes the tightest device's fit. The
            # queues are charged before the dense budget: they scale with the chunk
            # size and, at the auto chunk size, are the same order as the dense maps.
            # ``n_beamlets * per_batch`` bounds the block a group can ever launch, so
            # this over- rather than under-states the queue cost.
            def _fit(d: str) -> int:
                lanes_d = min(concurrent_batches, n_batches) if wp.get_device(d).is_cuda else 1
                chunk_d = min(self._chunk_histories(d), n_beamlets * per_batch)
                return _auto_group_size(
                    _device_free_bytes(d),
                    scoring.n_voxels,
                    n_beamlets,
                    lanes_d,
                    _queue_bytes(chunk_d, self.queue_factor, lanes_d),
                )

            beamlet_group_size = min(_fit(d) for d in device_list)

        # Beamlet groups are independent — no cross-group reduction — so sharding them
        # over devices needs no communication at all. Assignment is greedy from a
        # shared ordered queue: a device pulls the next group the moment it is free,
        # so an idle device always takes new work before any deeper concurrency
        # (batch lanes) on a busy one — the scheduling preference this engine
        # promises. Each column is computed wholly on one device, so scheduling can
        # never change a column's value on a given device, only which device
        # produces it (test-pinned). Over *identical* devices the whole Dij is
        # therefore bit-identical however the pulls interleave; over a mixed set
        # (e.g. cuda + cpu) the column-to-device assignment is timing-dependent run
        # to run, each column still bit-equal to a whole run of its computing device
        # — pass a single device where strict run-to-run reproducibility on mixed
        # hardware matters. The energy books are exact integer quanta either way.
        group_starts = list(range(0, n_beamlets, beamlet_group_size))
        pending = deque(group_starts)
        pending_lock = threading.Lock()

        def _next_group() -> int | None:
            with pending_lock:
                return pending.popleft() if pending else None

        assembler = DijAssembler(
            grid_shape=scoring.shape,
            n_beamlets=n_beamlets,
            n_histories_per_beamlet=n_histories_per_beamlet,
            n_batches=n_batches,
            truncation=truncation,
            correlated=correlated,
            scoring_mode=scoring_mode,
            provenance=self._provenance(
                seed,
                pcut,
                ecut,
                msc_model,
                step_energy_fraction,
                deposit_resolution_cm,
                devices=device_list,
            ),
        )

        emitter = ProgressEmitter(progress, n_beamlets * n_histories_per_beamlet)
        args = dict(
            source=source,
            n_histories_per_beamlet=n_histories_per_beamlet,
            n_batches=n_batches,
            per_batch=per_batch,
            seed=seed,
            pcut=pcut,
            ecut=ecut,
            step_energy_fraction=step_energy_fraction,
            deposit_resolution=deposit_resolution,
            msc_model_gs=msc_model_gs,
            transport_electrons=transport_electrons,
            truncation=truncation,
            correlated=correlated,
            beamlet_group_size=beamlet_group_size,
            scoring=scoring,
            dose_to_water=dose_to_water,
            concurrent_batches=concurrent_batches,
            emitter=emitter,
        )
        # Kernel modules are loaded up front whenever any host thread beyond this
        # one will launch (device workers or batch lanes): warp's module loading is
        # not thread-safe. Everything after takes its device explicitly, so the
        # threads share no warp state.
        if len(device_list) > 1 or concurrent_batches > 1:
            for d in device_list:
                wp.load_module(kernels, device=d)
        if len(device_list) == 1:
            shard_results = [self._run_dij_shard(device_list[0], _next_group, **args)]
        else:
            # One host thread per device: warp launches are asynchronous but the drain
            # loop's count readbacks block, so a single thread would serialize the
            # devices on those readbacks.
            with ThreadPoolExecutor(max_workers=len(device_list)) as pool:
                futures = [
                    pool.submit(self._run_dij_shard, d, _next_group, **args) for d in device_list
                ]
                shard_results = [f.result() for f in futures]

        # Energy books in exact integer quanta (Python ints, unbounded), converted
        # to MeV once at the end: float accumulation order would otherwise make
        # the tallies — unlike the matrix — depend on the group size. Summed over
        # shards in device-list order, so the books do not depend on which device
        # finished first.
        deposited_quanta = sum(r.deposited_quanta for r in shard_results)
        escaped_quanta = sum(r.escaped_quanta for r in shard_results)
        unscored_quanta = sum(r.unscored_quanta for r in shard_results)
        emitted_quanta = sum(r.emitted_quanta for r in shard_results)
        emitted_energy = sum(r.emitted_energy for r in shard_results)

        # The assembler takes blocks in ascending, gap-free beamlet order; shards
        # finish in whatever order the devices happen to, so feed it from the merged
        # map rather than as the blocks arrive.
        blocks = {gs: block for r in shard_results for gs, block in r.blocks.items()}
        for group_start in group_starts:
            counts, indices, dose, sigma = blocks[group_start]
            assembler.add_sparse_block(group_start, counts, indices, dose, sigma)

        use_lattice = isinstance(source, BeamletGridSource)
        if use_lattice:
            emitted_energy = n_beamlets * n_histories_per_beamlet * source.max_energy
        elif emitted_quanta:
            emitted_energy = float(emitted_quanta) * ENERGY_QUANTUM_MEV
        return assembler.finalize(
            energy_emitted=emitted_energy,
            energy_deposited=deposited_quanta * ENERGY_QUANTUM_MEV,
            energy_escaped=escaped_quanta * ENERGY_QUANTUM_MEV,
            energy_unscored=unscored_quanta * ENERGY_QUANTUM_MEV,
        )

    def _run_dij_shard(
        self,
        device: str,
        next_group: Callable[[], int | None],
        *,
        source,
        n_histories_per_beamlet: int,
        n_batches: int,
        per_batch: int,
        seed: int,
        pcut: float,
        ecut: float,
        step_energy_fraction: float,
        deposit_resolution: float,
        msc_model_gs: int,
        transport_electrons: bool,
        truncation: float,
        correlated: bool,
        beamlet_group_size: int,
        scoring: ScoringGrid,
        dose_to_water: int,
        concurrent_batches: int,
        emitter: ProgressEmitter,
    ) -> _DijShard:
        """Pull beamlet groups from the shared queue and run them on one device.

        Owns every device resource it touches (geometry, tables, queues, dense maps,
        energy counters), so shards on different devices share nothing and may run
        concurrently on their own host threads. Returns the shard's sparse blocks
        keyed by group start, plus its energy books in exact quanta for the caller
        to merge in a device-order-independent way.

        ``concurrent_batches`` > 1 transports that many batches of the current group
        concurrently, each lane on its own CUDA stream with its own queues, RNG
        slots, and quanta map (``_run_dij_group_lanes``); a cpu device has no
        streams and always runs the sequential path.
        """
        gi, density, material = self._upload_grid(device)
        si = _scoring_info(scoring, device)
        tab = self._upload_tables(source.max_energy, pcut, ecut, device, with_gs=msc_model_gs == 1)
        n_beamlets = source.n_beamlets
        n_voxels = scoring.n_voxels
        # Route by capability: the built-in lattice generates in-kernel from analytic
        # bounds; the exact spectral beamlet type generates in-kernel from its
        # uploaded spectrum tables (a subclass keeps pre-sampling — exact-type check,
        # since an emit/sample override must not be bypassed; the host route was
        # measured wall-dominant on CT-grade Dij runs); a source with a
        # warp_beamlet_sampler is generated in-kernel by a wrapped kernel; any other
        # source is host pre-sampled per beamlet and uploaded. Beamlet primaries are
        # photons carrying the source's statistical weight (the built-in routes are
        # unit-weight by construction; the other two take the weight from the
        # sampler/emit, matching the reference Dij).
        use_lattice = isinstance(source, BeamletGridSource)
        use_spectral = type(source) is SpectralBeamletSource
        beamlet_generator = None
        emitted_counter = None
        spectral_tables = None
        if not use_lattice and not use_spectral and source.warp_beamlet_sampler is not None:
            beamlet_generator = kernels.make_beamlet_generator_kernel(source.warp_beamlet_sampler)
            emitted_counter = wp.zeros(1, dtype=wp.int64, device=device)
        if use_spectral:
            spectrum = source.spectrum
            spectral_tables = (
                wp.array(spectrum.edges.astype(np.float32), dtype=float, device=device),
                wp.array(spectrum.cdf.astype(np.float32), dtype=float, device=device),
            )
            emitted_counter = wp.zeros(1, dtype=wp.int64, device=device)

        voxel_mass = wp.array(scoring.voxel_mass.reshape(n_voxels), dtype=wp.float64, device=device)
        escaped = wp.zeros(1, dtype=wp.int64, device=device)
        unscored = wp.zeros(1, dtype=wp.int64, device=device)
        deposited = wp.zeros(1, dtype=wp.int64, device=device)
        violations = wp.zeros(1, dtype=wp.int32, device=device)
        shard = _DijShard()
        deposited_quanta = 0
        escaped_quanta = 0
        unscored_quanta = 0

        # Group-invariant device buffers, allocated once at the largest group's size
        # and reused per group. Allocating these *inside* the loop costs twice their
        # footprint at the peak: the successor is allocated while the predecessor is
        # still referenced. A trailing short group uses a prefix of each buffer, which
        # is why every launch below is dimensioned from ``group`` rather than from the
        # buffer length.
        #
        # The batch axis is streamed, not stored: ``edep`` holds one batch at a time
        # and each batch is folded into the running float64 sums s1/s2, so the dense
        # cost is group * n_voxels * (8 + 8 + 8) independent of n_batches, rather than
        # group * n_batches * n_voxels * 8. s1/s2 are then overwritten in place with
        # the mean/sigma they imply (see the finalize_run launch), so the group needs
        # no separate dense output maps.
        max_group = min(beamlet_group_size, n_beamlets)
        shard_chunk = self._chunk_histories(device)
        max_capacity = max(1, min(shard_chunk, max_group * per_batch)) * self.queue_factor
        # A cpu device has no streams; lanes beyond the first would serialize on the
        # single device queue anyway, so they collapse to the sequential path.
        lanes = 1
        if concurrent_batches > 1 and wp.get_device(device).is_cuda:
            lanes = min(concurrent_batches, n_batches)
        lane_resources = [
            _LaneResources(
                stream=wp.Stream(device) if lanes > 1 else None,
                staging=(
                    wp.zeros(1, dtype=wp.int32, device="cpu", pinned=True) if lanes > 1 else None
                ),
                queues=[_upload_queue(max_capacity, device) for _ in range(4)],
                slots=wp.zeros(max_capacity, dtype=wp.uint32, device=device),
                edep=wp.zeros(max_group * n_voxels, dtype=wp.int64, device=device),
            )
            for _ in range(lanes)
        ]
        fold_stream = wp.Stream(device) if lanes > 1 else None
        s1 = wp.zeros(max_group * n_voxels, dtype=wp.float64, device=device)
        s2 = wp.zeros(max_group * n_voxels, dtype=wp.float64, device=device)
        total_q = wp.zeros(1, dtype=wp.int64, device=device)
        # Truncation runs one thread per (column, chunk) rather than one per column;
        # the layout is scheduling only and the output is independent of it. Sized
        # from the widest group and this device's SM count, so the launch fills the
        # card: the chunk count a group needs falls as the group widens. A trailing
        # short group reuses this layout — it launches proportionally fewer threads
        # for proportionally less work, at unchanged per-thread cost.
        n_chunks, voxel_chunk = kernels.truncation_chunk_layout(
            n_voxels, max_group, int(getattr(wp.get_device(device), "sm_count", 0) or 0)
        )
        col_max_dev = wp.zeros(max_group, dtype=wp.float64, device=device)
        chunk_counts_dev = wp.zeros(max_group * n_chunks, dtype=wp.int32, device=device)

        route_args = dict(
            source=source,
            beamlet_generator=beamlet_generator,
            emitted_counter=emitted_counter,
            use_lattice=use_lattice,
            use_spectral=use_spectral,
            spectral_tables=spectral_tables,
            seed=seed,
            n_per=n_histories_per_beamlet,
            per_batch=per_batch,
            correlated=correlated,
            gi=gi,
            si=si,
            density=density,
            material=material,
            tab=tab,
            escaped=escaped,
            unscored=unscored,
            deposited=deposited,
            violations=violations,
            pcut=pcut,
            ecut=ecut,
            step_energy_fraction=step_energy_fraction,
            deposit_resolution=deposit_resolution,
            msc_model_gs=msc_model_gs,
            transport_electrons=transport_electrons,
            dose_to_water=dose_to_water,
            device=device,
        )
        fold_args = dict(
            voxel_mass=voxel_mass,
            n_voxels=n_voxels,
            per_batch=per_batch,
            s1=s1,
            s2=s2,
            total_q=total_q,
            device=device,
        )

        while (group_start := next_group()) is not None:
            group = min(beamlet_group_size, n_beamlets - group_start)
            block_histories = group * per_batch  # one batch's block
            chunk = min(shard_chunk, block_histories)
            # The per-group upload for whichever in-kernel route is active: analytic
            # bounds for the lattice, aperture centres for the spectral fan.
            group_arrays = None
            if use_lattice:
                group_arrays = self._upload_lattice_bounds(source, group_start, group, device)
            elif use_spectral:
                group_arrays = self._upload_spectral_centers(source, group_start, group, device)
            # Clear only what this group uses: the dense maps are allocated at
            # max_group, so zero_() would charge a trailing short group the full
            # group's clear. Every dense launch below is dimensioned from ``group``,
            # so the tail beyond it is never read.
            span = group * n_voxels
            wp.launch(kernels.fill_float64, dim=span, inputs=[s1, 0.0], device=device)
            wp.launch(kernels.fill_float64, dim=span, inputs=[s2, 0.0], device=device)
            # The fold resets every entry it consumes, so a quanta map only needs
            # clearing once per group rather than once per batch. It is still cleared
            # here rather than relying on the reset alone: a shorter group resets only
            # its own prefix, and the buffer should not depend on a short group always
            # being the last one. Each lane keeps its own map, so clear them all.
            for res in lane_resources:
                wp.launch(kernels.fill_int64, dim=span, inputs=[res.edep, 0], device=device)
            total_q.zero_()
            escaped.zero_()
            unscored.zero_()
            deposited.zero_()

            if lanes == 1:
                # One drain per batch, folded into s1/s2 before the next batch
                # reuses edep. Batch order is the fold order (accumulate_dij_batch).
                res = lane_resources[0]
                for batch in range(n_batches):
                    shard.emitted_energy += self._run_dij_batch(
                        res,
                        group_start,
                        group,
                        batch,
                        chunk,
                        block_histories,
                        group_arrays,
                        **route_args,
                    )
                    self._fold_dij_batch(res.edep, group, None, **fold_args)
            else:
                self._run_dij_group_lanes(
                    lane_resources,
                    fold_stream,
                    group_start,
                    group,
                    n_batches,
                    chunk,
                    block_histories,
                    group_arrays,
                    shard,
                    route_args,
                    fold_args,
                )

            wp.synchronize_device(device)
            if int(violations.numpy()[0]) != 0:
                raise RuntimeError(
                    "Woodcock majorant violated in the kernel despite table headroom. "
                    "The geometry contains material or density the majorant "
                    "declaration did not cover."
                )
            # Turn the group's accumulated sums into per-column dose mean and sigma,
            # in place: each thread reads s1[tid]/s2[tid] and writes only its own
            # element, so aliasing the outputs onto the inputs is safe and saves two
            # dense group * n_voxels float64 maps. Only these read back, never a
            # per-batch buffer. total_q summed the physical quanta over the batch
            # loop (dose-to-medium book); dose-to-water uses its counter.
            wp.launch(
                kernels.finalize_run,
                dim=group * n_voxels,
                inputs=[s1, s2, n_batches, s1, s2],
                device=device,
            )
            mean_dev, sigma_dev = s1, s2
            wp.synchronize_device(device)
            if dose_to_water != 0:
                deposited_quanta += int(deposited.numpy()[0])
            else:
                deposited_quanta += int(total_q.numpy()[0])
            escaped_quanta += int(escaped.numpy()[0])
            unscored_quanta += int(unscored.numpy()[0])

            # Truncate + compact each column on the device; only the surviving sparse
            # CSC entries read back, never the dense per-column dose maps. col_max is
            # a maximum and the keep test uses the same float64 threshold product as
            # DijAssembler.add_block, so this is byte-identical to host truncation on
            # these maps. Each column's sweep is split over ``n_chunks`` threads (a
            # single thread per column left the GPU essentially idle through the
            # dominant phase of the Dij); determinism survives because the maximum is
            # order-independent and each chunk writes from the exclusive prefix of the
            # chunks before it *in its own column*, so rows stay ascending.
            col_max_dev.zero_()
            wp.launch(
                kernels.column_max,
                dim=group * n_voxels,
                inputs=[mean_dev, n_voxels, col_max_dev],
                device=device,
            )
            wp.launch(
                kernels.count_kept_per_chunk,
                dim=group * n_chunks,
                inputs=[
                    mean_dev,
                    n_voxels,
                    n_chunks,
                    voxel_chunk,
                    float(truncation),
                    col_max_dev,
                    chunk_counts_dev,
                ],
                device=device,
            )
            wp.synchronize_device(device)
            per_chunk = chunk_counts_dev.numpy()[: group * n_chunks].reshape(group, n_chunks)
            counts = per_chunk.sum(axis=1).astype(np.int32)
            column_base = np.zeros(group, dtype=np.int64)
            np.cumsum(counts[:-1], out=column_base[1:])  # exclusive prefix over columns
            within_column = np.zeros((group, n_chunks), dtype=np.int64)
            np.cumsum(per_chunk[:, :-1], axis=1, out=within_column[:, 1:])  # ... and within one
            chunk_base = (column_base[:, None] + within_column).reshape(-1).astype(np.int32)
            nnz = int(counts.sum())
            out_indices = wp.zeros(nnz, dtype=wp.int64, device=device)
            out_dose = wp.zeros(nnz, dtype=wp.float64, device=device)
            out_sigma = wp.zeros(nnz, dtype=wp.float64, device=device)
            wp.launch(
                kernels.compact_column_chunked,
                dim=group * n_chunks,
                inputs=[
                    mean_dev,
                    sigma_dev,
                    n_voxels,
                    n_chunks,
                    voxel_chunk,
                    float(truncation),
                    col_max_dev,
                    wp.array(chunk_base, dtype=wp.int32, device=device),
                    out_indices,
                    out_dose,
                    out_sigma,
                ],
                device=device,
            )
            wp.synchronize_device(device)
            # Copy, do not alias: on a cpu device ``numpy()`` is a *view* of the warp
            # array, and these blocks outlive both the reused ``counts_dev`` and this
            # group's compaction buffers. (The blocks are the sparse result itself, so
            # owning them costs nothing beyond what the DijResult holds anyway.)
            shard.blocks[group_start] = (
                counts.copy(),
                out_indices.numpy().copy(),
                out_dose.numpy().copy(),
                out_sigma.numpy().copy(),
            )
            # Tick only now, with the group *complete*. Ticking right after transport
            # instead charged each group's reduction, truncation and readback to its
            # successor's interval: the first group then reported a rate no later one
            # could reach, and the final group's share landed after the last tick as
            # silence at 100 percent. The readback above is part of the group's cost,
            # so the tick follows it; ``numpy()`` has already synchronized, so this
            # still lands on an existing hard sync and adds none.
            emitter.tick(group * n_histories_per_beamlet)

        shard.deposited_quanta = deposited_quanta
        shard.escaped_quanta = escaped_quanta
        shard.unscored_quanta = unscored_quanta
        if emitted_counter is not None:
            shard.emitted_quanta = int(emitted_counter.numpy()[0])
        return shard

    def _run_dij_batch(
        self,
        res: _LaneResources,
        group_start,
        group,
        batch,
        chunk,
        block_histories,
        group_arrays,
        *,
        source,
        beamlet_generator,
        emitted_counter,
        use_lattice,
        use_spectral,
        spectral_tables,
        seed,
        n_per,
        per_batch,
        correlated,
        gi,
        si,
        density,
        material,
        tab,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        device,
    ) -> float:
        """Generate and drain one batch of one group with a lane's resources.

        The single routing point for the four generation routes; sequential and
        lane execution differ only in which :class:`_LaneResources` they pass.
        ``group_arrays`` carries the active in-kernel route's per-group upload
        (lattice bounds or spectral aperture centres). Returns the batch's
        host-summed emitted energy (nonzero only on the pre-sampled route; the
        in-kernel routes book emitted energy on the device).
        """
        if use_lattice:
            self._generate_group_lattice(
                source,
                group_arrays,
                group_start,
                group,
                seed,
                n_per,
                per_batch,
                batch,
                correlated,
                chunk,
                block_histories,
                gi,
                si,
                density,
                material,
                tab,
                res.queues,
                res.slots,
                res.edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
                device,
                res.stream,
                res.staging,
            )
            return 0.0
        if use_spectral:
            self._generate_group_spectral(
                source,
                spectral_tables,
                group_arrays,
                group_start,
                group,
                seed,
                n_per,
                per_batch,
                batch,
                correlated,
                chunk,
                block_histories,
                gi,
                si,
                density,
                material,
                tab,
                res.queues,
                res.slots,
                res.edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
                emitted_counter,
                device,
                res.stream,
                res.staging,
            )
            return 0.0
        if beamlet_generator is not None:
            self._generate_group_beamlet_wrapped(
                beamlet_generator,
                group_start,
                group,
                seed,
                n_per,
                per_batch,
                batch,
                correlated,
                chunk,
                block_histories,
                gi,
                si,
                density,
                material,
                tab,
                res.queues,
                res.slots,
                res.edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
                emitted_counter,
                device,
                res.stream,
                res.staging,
            )
            return 0.0
        return self._generate_group_presampled(
            source,
            group_start,
            group,
            seed,
            n_per,
            per_batch,
            batch,
            correlated,
            chunk,
            gi,
            si,
            density,
            material,
            tab,
            res.queues,
            res.slots,
            res.edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            res.stream,
            res.staging,
        )

    def _fold_dij_batch(
        self,
        edep,
        group,
        stream,
        *,
        voxel_mass,
        n_voxels,
        per_batch,
        s1,
        s2,
        total_q,
        device,
    ) -> None:
        """Fold one batch's quanta map into the running sums (accumulate_dij_batch)."""
        _launch(
            kernels.accumulate_dij_batch,
            group * n_voxels,
            [
                edep,
                voxel_mass,
                n_voxels,
                float(per_batch),
                float(ENERGY_QUANTUM_MEV),
                s1,
                s2,
                total_q,
            ],
            device,
            stream,
        )

    def _run_dij_group_lanes(
        self,
        lane_resources,
        fold_stream,
        group_start,
        group,
        n_batches,
        chunk,
        block_histories,
        group_arrays,
        shard,
        route_args,
        fold_args,
    ) -> None:
        """Transport up to ``len(lane_resources)`` batches of one group concurrently.

        Each lane is a host thread driving its own CUDA stream: lane ``l`` takes
        batches ``l, l+lanes, ...``, so the batch-to-lane map is static and the
        transports are fully independent (private queues, slots, quanta map; the
        shared energy counters take int64 atomics, order-independent by integer
        associativity).

        The one float64 reduction — the fold of batch sums into ``s1``/``s2`` — is
        forced into batch order: a lane finishing batch ``b`` waits its turn on the
        shared counter, issues the fold on the single fold stream, and synchronizes
        it before releasing the next turn (and before reusing its own quanta map).
        The fold sequence is therefore the identical left fold the sequential path
        performs, which is what makes ``concurrent_batches`` bitwise inert
        (test-pinned). The wait costs little: folds are one cheap pass over
        ``group * n_voxels`` against a whole batch transport.

        The pre-sampled route's host-side emitted sums are collected per batch and
        folded into the shard in batch order after the join, so that float sum
        cannot depend on lane timing either.
        """
        device = route_args["device"]
        lanes = len(lane_resources)
        # The group-start zeroing and any bounds upload ran on the default stream;
        # order them before the first lane-stream launch.
        wp.synchronize_device(device)
        state = {"next_fold": 0, "failed": False}
        cond = threading.Condition()
        emitted_by_batch = [0.0] * n_batches

        def lane(lane_index: int) -> None:
            res = lane_resources[lane_index]
            try:
                for b in range(lane_index, n_batches, lanes):
                    # No per-batch clear: the group-start clear ran on the default
                    # stream (ordered by the synchronize above) and every fold since
                    # has reset the entries it consumed. A lane's own fold is awaited
                    # under ``cond`` below before it loops, so its map is clean here.
                    emitted_by_batch[b] = self._run_dij_batch(
                        res,
                        group_start,
                        group,
                        b,
                        chunk,
                        block_histories,
                        group_arrays,
                        **route_args,
                    )
                    # Transport is complete on the device here: the drain loop's
                    # final count readback synchronized the lane stream.
                    with cond:
                        while state["next_fold"] != b and not state["failed"]:
                            cond.wait()
                        if state["failed"]:
                            return
                        self._fold_dij_batch(res.edep, group, fold_stream, **fold_args)
                        wp.synchronize_stream(fold_stream)
                        state["next_fold"] = b + 1
                        cond.notify_all()
            except BaseException:
                with cond:
                    state["failed"] = True
                    cond.notify_all()
                raise

        with ThreadPoolExecutor(max_workers=lanes) as pool:
            futures = [pool.submit(lane, index) for index in range(lanes)]
            for f in futures:
                f.result()
        for emitted in emitted_by_batch:
            shard.emitted_energy += emitted

    def _run_forward_batch_lanes(
        self,
        lane_resources,
        fold_stream,
        n_batches,
        per_batch,
        chunk,
        **route_args,
    ) -> float:
        """Transport forward-dose batches concurrently and fold them in batch order.

        This is the open-field twin of :meth:`_run_dij_group_lanes`. Fixed-point
        scoring and the energy books are associative integer atomics; only the
        float64 batch-statistics reduction is order-sensitive, so one fold stream
        consumes completed lane maps in ascending batch order. A lane's map is
        cleared only after its fold has completed and before that lane reuses it.
        """
        device = route_args["device"]
        n_voxels = route_args["n_voxels"]
        voxel_mass = route_args["voxel_mass"]
        s1 = route_args["s1"]
        s2 = route_args["s2"]
        dep_total = route_args["dep_total"]
        emitter = route_args["emitter"]
        lanes = len(lane_resources)
        wp.synchronize_device(device)
        state = {"next_fold": 0, "failed": False}
        cond = threading.Condition()
        emitted_by_batch = [0.0] * n_batches

        def lane(lane_index: int) -> None:
            res = lane_resources[lane_index]
            try:
                for batch in range(lane_index, n_batches, lanes):
                    emitted_by_batch[batch] = self._run_forward_batch(
                        res,
                        batch,
                        per_batch,
                        chunk,
                        **route_args,
                    )
                    # The drain loop's final count readback has synchronized this
                    # lane stream. Wait until every earlier batch has been folded.
                    with cond:
                        while state["next_fold"] != batch and not state["failed"]:
                            cond.wait()
                        if state["failed"]:
                            return
                        _launch(
                            kernels.accumulate_run_batch,
                            n_voxels,
                            [
                                res.edep,
                                voxel_mass,
                                float(per_batch),
                                float(ENERGY_QUANTUM_MEV),
                                s1,
                                s2,
                                dep_total,
                            ],
                            device,
                            fold_stream,
                        )
                        wp.synchronize_stream(fold_stream)
                        # The fold does not reset the forward map (unlike the Dij
                        # fold), so clear it on this lane before its next transport.
                        _launch(kernels.fill_int64, n_voxels, [res.edep, 0], device, res.stream)
                        emitter.tick(per_batch)
                        state["next_fold"] = batch + 1
                        cond.notify_all()
            except BaseException:
                with cond:
                    state["failed"] = True
                    cond.notify_all()
                raise

        with ThreadPoolExecutor(max_workers=lanes) as pool:
            futures = [pool.submit(lane, index) for index in range(lanes)]
            for future in futures:
                future.result()
        return float(sum(emitted_by_batch))

    def _run_forward_batch(
        self,
        res: _LaneResources,
        batch,
        per_batch,
        chunk,
        *,
        source,
        kind,
        in_kernel,
        is_device_ps,
        is_spectral,
        is_spectral_mask,
        generator,
        spectral_tables,
        mask_table,
        seed,
        gi,
        si,
        density,
        material,
        tab,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        **_fold_args,
    ) -> float:
        """Transport one complete forward statistical batch on one lane."""
        history = batch * per_batch
        remaining = per_batch
        emitted_energy = 0.0
        while remaining > 0:
            n_chunk = min(chunk, remaining)
            common = (
                seed,
                history,
                n_chunk,
                gi,
                si,
                density,
                material,
                tab,
                res.queues,
                res.slots,
                res.edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
            )
            if in_kernel:
                self._transport_chunk(
                    source,
                    kind,
                    *common,
                    device,
                    res.stream,
                    res.staging,
                )
            elif is_device_ps:
                self._transport_chunk_device_buffer(
                    source,
                    *common,
                    emitted,
                    device,
                    res.stream,
                    res.staging,
                )
            elif is_spectral_mask:
                self._transport_chunk_spectral_mask(
                    source,
                    spectral_tables,
                    mask_table,
                    *common,
                    emitted,
                    device,
                    res.stream,
                    res.staging,
                )
            elif is_spectral:
                self._transport_chunk_spectral(
                    source,
                    spectral_tables,
                    *common,
                    emitted,
                    device,
                    res.stream,
                    res.staging,
                )
            elif generator is not None:
                self._transport_chunk_wrapped(
                    generator,
                    *common,
                    emitted,
                    device,
                    res.stream,
                    res.staging,
                )
            else:
                emitted_energy += self._transport_chunk_presampled(
                    source,
                    *common,
                    device,
                    res.stream,
                    res.staging,
                )
            history += n_chunk
            remaining -= n_chunk
        return emitted_energy

    # -- internals --------------------------------------------------------------

    def _transport_chunk(
        self,
        source,
        kind,
        seed,
        history_offset,
        n_chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Generate one chunk of primaries and drain all queues."""
        q_particle, q_particle_alt, q_other, q_other_alt = queues
        for q in queues:
            _reset_count(q, device, stream)

        if kind == PHOTON:
            q_photon, q_photon_alt = q_particle, q_particle_alt
            q_electron, q_electron_alt = q_other, q_other_alt
            target = q_photon
        else:
            q_electron, q_electron_alt = q_particle, q_particle_alt
            q_photon, q_photon_alt = q_other, q_other_alt
            target = q_electron

        self._generate(source, kind, seed, history_offset, n_chunk, target, slots, device, stream)
        _set_count(target, n_chunk, device, stream)

        self._drain_queues(
            q_photon,
            q_photon_alt,
            q_electron,
            q_electron_alt,
            gi,
            si,
            density,
            material,
            tab,
            slots,
            edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            stream,
            staging,
        )

    def _transport_chunk_spectral(
        self,
        source,
        spectral_tables,
        seed,
        history_offset,
        n_chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Generate one spectral-beam chunk in-kernel, then drain all queues.

        Photons only, at fixed thread slots into the photon queue (the mono-beam
        idiom: an explicit count write, no atomics); the kernel books each primary's
        energy into ``emitted``, since a polyenergetic ledger has no analytic total.
        """
        q_photon, q_photon_alt, q_electron, q_electron_alt = queues
        for q in queues:
            _reset_count(q, device, stream)

        sp_edges, sp_cdf = spectral_tables
        focal = source.focal_point
        center = source.center
        u_axis = source.u_axis
        v_axis = source.v_axis
        _launch(
            kernels.generate_spectral_beam,
            n_chunk,
            [
                seed,
                history_offset,
                sp_edges,
                sp_cdf,
                int(sp_cdf.shape[0]),
                focal[0],
                focal[1],
                focal[2],
                center[0],
                center[1],
                center[2],
                u_axis[0],
                u_axis[1],
                u_axis[2],
                v_axis[0],
                v_axis[1],
                v_axis[2],
                source.width_u,
                source.width_v,
                slots,
                q_photon,
                emitted,
            ],
            device,
            stream,
        )
        _set_count(q_photon, n_chunk, device, stream)

        self._drain_queues(
            q_photon,
            q_photon_alt,
            q_electron,
            q_electron_alt,
            gi,
            si,
            density,
            material,
            tab,
            slots,
            edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            stream,
            staging,
        )

    def _transport_chunk_spectral_mask(
        self,
        source,
        spectral_tables,
        mask_table,
        seed,
        history_offset,
        n_chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Generate a spectral fan and transmission-mask it entirely on-device."""
        q_photon, q_photon_alt, q_electron, q_electron_alt = queues
        for q in queues:
            _reset_count(q, device, stream)

        inner = source.inner
        sp_edges, sp_cdf = spectral_tables
        focal = inner.focal_point
        center = inner.center
        source_u = inner.u_axis
        source_v = inner.v_axis
        mask_center = source.plane_center
        mask_u = source.u_axis
        mask_v = source.v_axis
        mask_n_u, mask_n_v = source.mask.shape
        _launch(
            kernels.generate_spectral_masked_beam,
            n_chunk,
            [
                seed,
                history_offset,
                sp_edges,
                sp_cdf,
                int(sp_cdf.shape[0]),
                focal[0],
                focal[1],
                focal[2],
                center[0],
                center[1],
                center[2],
                source_u[0],
                source_u[1],
                source_u[2],
                source_v[0],
                source_v[1],
                source_v[2],
                inner.width_u,
                inner.width_v,
                mask_table,
                mask_n_u,
                mask_n_v,
                mask_center[0],
                mask_center[1],
                mask_center[2],
                mask_u[0],
                mask_u[1],
                mask_u[2],
                mask_v[0],
                mask_v[1],
                mask_v[2],
                source.width_u,
                source.width_v,
                slots,
                q_photon,
                emitted,
            ],
            device,
            stream,
        )

        # The generator atomically compacts exactly-zero mask weights, so its queue
        # count is already the number of primaries that need transport.
        self._drain_queues(
            q_photon,
            q_photon_alt,
            q_electron,
            q_electron_alt,
            gi,
            si,
            density,
            material,
            tab,
            slots,
            edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            stream,
            staging,
        )

    def _transport_chunk_wrapped(
        self,
        generator,
        seed,
        history_offset,
        n_chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Generate one chunk in-kernel via a wrapped user sampler, then drain.

        The wrapped generator (:func:`~pyradmc.backends.warp.kernels.make_generator_kernel`)
        pushes each primary into the photon or electron queue by its kind at an atomic
        slot and books emitted weight-energy into ``emitted``; the queue counts are then
        whatever the pushes set, so — unlike the mono-beam path — no explicit count is
        written.
        """
        q_photon, q_photon_alt, q_electron, q_electron_alt = queues
        for q in queues:
            _reset_count(q, device, stream)

        _launch(
            generator,
            n_chunk,
            [seed, history_offset, q_photon, q_electron, slots, emitted],
            device,
            stream,
        )

        self._drain_queues(
            q_photon,
            q_photon_alt,
            q_electron,
            q_electron_alt,
            gi,
            si,
            density,
            material,
            tab,
            slots,
            edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            stream,
            staging,
        )

    def _transport_chunk_presampled(
        self,
        source,
        seed,
        history_offset,
        n_chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        device,
        stream=None,
        staging=None,
    ) -> float:
        """Host-sample a chunk via ``source.sample_batch``, seed queues by kind, drain.

        The general (pre-sampling) route: works for any source with a ``sample_batch``
        — a phase space, or a user :class:`~pyradmc.geometry.source.Source` using the
        emit-based default. Returns the chunk's emitted energy: sum of
        ``weight * energy`` over the sampled records, plus ``weight * 2 m_e c^2`` for
        each positron whose annihilation photons the device books into
        deposited/escaped (so the emitted = deposited + escaped ledger closes, as on
        the reference backend).
        """
        q_photon, q_photon_alt, q_electron, q_electron_alt = queues
        for q in queues:
            _reset_count(q, device, stream)

        batch = source.sample_batch(seed, history_offset, n_chunk)
        pt = batch["particle_type"]
        hist = (history_offset + np.arange(n_chunk, dtype=np.int64)).astype(np.int32)
        transport_kind = _IAEA_TO_TRANSPORT[pt]

        # An exactly-zero statistical weight contributes nothing to dose or any
        # energy book. Do not spend a full transport history on it. This matters
        # especially for deterministic transmission masks, whose closed pixels
        # deliberately emit weight-zero records to preserve the source RNG stream.
        # The surviving records retain their global history keys, so compaction
        # changes neither their random streams nor same-device reproducibility.
        active = batch["weight"] != 0.0
        photon = active & (pt == 1)  # electrons (2) and positrons (3) share the e- queue
        charged = active & (pt != 1)
        zero_tag = np.zeros(n_chunk, dtype=np.int32)  # a plain run scores one column
        self._seed_queue(
            q_photon,
            seed,
            hist[photon],
            zero_tag[photon],
            transport_kind[photon],
            batch,
            photon,
            device,
            stream,
        )
        self._seed_queue(
            q_electron,
            seed,
            hist[charged],
            zero_tag[charged],
            transport_kind[charged],
            batch,
            charged,
            device,
            stream,
        )

        self._drain_queues(
            q_photon,
            q_photon_alt,
            q_electron,
            q_electron_alt,
            gi,
            si,
            density,
            material,
            tab,
            slots,
            edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            stream,
            staging,
        )

        latent = np.where(pt == 3, 2.0 * ELECTRON_MASS_MEV, 0.0)
        return float(
            np.sum(
                batch["weight"].astype(np.float64) * (batch["energy"].astype(np.float64) + latent)
            )
        )

    def _transport_chunk_device_buffer(
        self,
        source,
        seed,
        history_offset,
        n_chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Seed the transport queues straight from a device pre-solve buffer, then drain.

        The no-copy path (``DevicePhaseSpace``): no host ``sample_batch`` and no
        re-upload — one kernel samples a record per history from the on-device
        population and atomic-appends it to the photon or electron queue as a source
        primary, booking the emitted weight-energy into ``emitted``. Transport is
        then identical to every other route.
        """
        q_photon, q_photon_alt, q_electron, q_electron_alt = queues
        for q in queues:
            _reset_count(q, device, stream)
        buf = source.buffer
        _launch(
            kernels.generate_from_exit_buffer,
            n_chunk,
            [
                seed,
                seed ^ _PRESOLVE_SAMPLING_SALT,
                history_offset,
                source.count,
                buf.particle_type,
                buf.energy,
                buf.x,
                buf.y,
                buf.z,
                buf.ux,
                buf.uy,
                buf.uz,
                buf.weight,
                q_photon,
                q_electron,
                slots,
                emitted,
            ],
            device,
            stream,
        )
        self._drain_queues(
            q_photon,
            q_photon_alt,
            q_electron,
            q_electron_alt,
            gi,
            si,
            density,
            material,
            tab,
            slots,
            edep,
            escaped,
            unscored,
            deposited,
            violations,
            pcut,
            ecut,
            step_energy_fraction,
            deposit_resolution,
            msc_model_gs,
            transport_electrons,
            dose_to_water,
            device,
            stream,
            staging,
        )

    def _seed_queue(
        self, queue, seed, hist_g, beamlet_g, kind_g, batch, mask, device, stream=None
    ) -> None:
        """Upload one kind-group's primaries (tagged by ``beamlet_g``) into ``queue``."""
        n = int(hist_g.shape[0])
        if n == 0:
            _set_count(queue, 0, device, stream)
            return
        uploads = {
            name: wp.array(batch[name][mask], dtype=float, device=device)
            for name in ("energy", "x", "y", "z", "ux", "uy", "uz", "weight")
        }
        uploads["hist"] = wp.array(hist_g, dtype=wp.int32, device=device)
        uploads["beamlet"] = wp.array(beamlet_g, dtype=wp.int32, device=device)
        uploads["kind"] = wp.array(kind_g, dtype=wp.int32, device=device)
        if stream is not None:
            # Host uploads run on the device's *default* stream; the launch below
            # runs on the lane's. Wait for the copies (only — other lanes' streams
            # are untouched) so the lane cannot read a half-arrived buffer.
            wp.synchronize_stream(wp.get_stream(device))
        _launch(
            kernels.generate_from_upload,
            n,
            [
                seed,
                uploads["hist"],
                uploads["beamlet"],
                uploads["kind"],
                uploads["energy"],
                uploads["x"],
                uploads["y"],
                uploads["z"],
                uploads["ux"],
                uploads["uy"],
                uploads["uz"],
                uploads["weight"],
                queue,
            ],
            device,
            stream,
        )
        _set_count(queue, n, device, stream)

    def _drain_queues(
        self,
        q_photon,
        q_photon_alt,
        q_electron,
        q_electron_alt,
        gi,
        si,
        density,
        material,
        tab,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Ping-pong the photon and electron kernels until every queue is empty.

        With ``stream`` set (a Dij batch lane), every launch, count reset, and
        count readback is ordered on that stream, so lanes on the same device
        never observe each other's queues.
        """
        while True:
            n_photon = _queue_count(q_photon, stream, staging)
            if n_photon > 0:
                _launch(
                    kernels.photon_kernel,
                    n_photon,
                    [
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        q_photon,
                        q_electron,
                        q_photon_alt,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        pcut,
                        ecut,
                        1 if transport_electrons else 0,
                        dose_to_water,
                        violations,
                    ],
                    device,
                    stream,
                )
                _reset_count(q_photon, device, stream)
                q_photon, q_photon_alt = q_photon_alt, q_photon

            n_electron = _queue_count(q_electron, stream, staging)
            if n_electron > 0:
                _launch(
                    kernels.electron_kernel,
                    n_electron,
                    [
                        gi,
                        si,
                        density,
                        material,
                        tab,
                        q_electron,
                        q_photon,
                        q_electron_alt,
                        slots,
                        edep,
                        escaped,
                        unscored,
                        deposited,
                        pcut,
                        ecut,
                        step_energy_fraction,
                        deposit_resolution,
                        msc_model_gs,
                        dose_to_water,
                    ],
                    device,
                    stream,
                )
                _reset_count(q_electron, device, stream)
                q_electron, q_electron_alt = q_electron_alt, q_electron

            if n_photon == 0 and n_electron == 0:
                break

    def _generate(
        self, source, kind, seed, history_offset, n, queue, slots, device, stream=None
    ) -> None:
        if isinstance(source, PencilBeamSource):
            _launch(
                kernels.generate_pencil_beam,
                n,
                [
                    seed,
                    history_offset,
                    kind,
                    source.energy,
                    source.position[0],
                    source.position[1],
                    source.position[2],
                    source.direction[0],
                    source.direction[1],
                    source.direction[2],
                    queue,
                ],
                device,
                stream,
            )
        elif isinstance(source, ParallelBeamSource):
            _launch(
                kernels.generate_parallel_beam,
                n,
                [
                    seed,
                    history_offset,
                    kind,
                    source.energy,
                    source.z,
                    source.x_range[0],
                    source.x_range[1] - source.x_range[0],
                    source.y_range[0],
                    source.y_range[1] - source.y_range[0],
                    slots,
                    queue,
                ],
                device,
                stream,
            )
        else:
            raise ValueError(f"unsupported source type {type(source).__name__}")

    def _upload_lattice_bounds(self, source, group_start, group, device):
        """Upload one group's beamlet bounds once, shared by every batch and lane.

        Hoisted out of the per-batch generation for two reasons: the bounds do not
        change across batches, and host uploads run on the device's *default*
        stream — done here, before any lane stream launches, the single
        ``synchronize_device`` in the group loop orders them for every lane.
        """
        bounds = np.array(
            [source.beamlet_bounds(group_start + k) for k in range(group)], dtype=np.float64
        )
        return (
            wp.array(bounds[:, 0].astype(np.float32), dtype=float, device=device),
            wp.array((bounds[:, 1] - bounds[:, 0]).astype(np.float32), dtype=float, device=device),
            wp.array(bounds[:, 2].astype(np.float32), dtype=float, device=device),
            wp.array((bounds[:, 3] - bounds[:, 2]).astype(np.float32), dtype=float, device=device),
        )

    def _generate_group_lattice(
        self,
        source,
        lattice_bounds,
        group_start,
        group,
        seed,
        n_per,
        per_batch,
        batch,
        correlated,
        chunk,
        block_histories,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Built-in lattice: generate one batch of the group's block in-kernel, in chunks."""
        x_lo, x_extent, y_lo, y_extent = lattice_bounds
        t = 0
        while t < block_histories:
            n_chunk = min(chunk, block_histories - t)
            for q in queues:
                _reset_count(q, device, stream)
            _launch(
                kernels.generate_beamlet_lattice,
                n_chunk,
                [
                    seed,
                    group_start,
                    n_per,
                    per_batch,
                    batch,
                    t,
                    1 if correlated else 0,
                    source.energy,
                    source.z,
                    x_lo,
                    x_extent,
                    y_lo,
                    y_extent,
                    slots,
                    queues[0],
                ],
                device,
                stream,
            )
            _set_count(queues[0], n_chunk, device, stream)
            self._drain_queues(
                queues[0],
                queues[1],
                queues[2],
                queues[3],
                gi,
                si,
                density,
                material,
                tab,
                slots,
                edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
                device,
                stream,
                staging,
            )
            t += n_chunk

    def _upload_spectral_centers(self, source, group_start, group, device):
        """Upload one group's aperture centres once, shared by every batch and lane.

        The spectral twin of :meth:`_upload_lattice_bounds`, with the same
        default-stream ordering rationale.
        """
        centers = np.array(
            [source.centers[group_start + k] for k in range(group)], dtype=np.float64
        )
        return (
            wp.array(centers[:, 0].astype(np.float32), dtype=float, device=device),
            wp.array(centers[:, 1].astype(np.float32), dtype=float, device=device),
            wp.array(centers[:, 2].astype(np.float32), dtype=float, device=device),
        )

    def _generate_group_spectral(
        self,
        source,
        spectral_tables,
        group_arrays,
        group_start,
        group,
        seed,
        n_per,
        per_batch,
        batch,
        correlated,
        chunk,
        block_histories,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Built-in spectral Dij route: generate one batch of the group in-kernel.

        Same per-batch block chunking as the lattice, with the spectrum inversion
        and divergent-fan geometry inline
        (:func:`~pyradmc.backends.warp.kernels.generate_spectral_beamlets`); the
        kernel books emitted energy into ``emitted`` per primary.
        """
        sp_edges, sp_cdf = spectral_tables
        cx, cy, cz = group_arrays
        focal = source.focal_point
        u_axis = source.u_axis
        v_axis = source.v_axis
        t = 0
        while t < block_histories:
            n_chunk = min(chunk, block_histories - t)
            for q in queues:
                _reset_count(q, device, stream)
            _launch(
                kernels.generate_spectral_beamlets,
                n_chunk,
                [
                    seed,
                    group_start,
                    n_per,
                    per_batch,
                    batch,
                    t,
                    1 if correlated else 0,
                    sp_edges,
                    sp_cdf,
                    int(sp_cdf.shape[0]),
                    focal[0],
                    focal[1],
                    focal[2],
                    u_axis[0],
                    u_axis[1],
                    u_axis[2],
                    v_axis[0],
                    v_axis[1],
                    v_axis[2],
                    source.width_u,
                    source.width_v,
                    cx,
                    cy,
                    cz,
                    slots,
                    emitted,
                    queues[0],
                ],
                device,
                stream,
            )
            _set_count(queues[0], n_chunk, device, stream)
            self._drain_queues(
                queues[0],
                queues[1],
                queues[2],
                queues[3],
                gi,
                si,
                density,
                material,
                tab,
                slots,
                edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
                device,
                stream,
                staging,
            )
            t += n_chunk

    def _generate_group_beamlet_wrapped(
        self,
        generator,
        group_start,
        group,
        seed,
        n_per,
        per_batch,
        batch,
        correlated,
        chunk,
        block_histories,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        emitted,
        device,
        stream=None,
        staging=None,
    ) -> None:
        """Advanced Dij route: generate one batch of the group in-kernel via a sampler.

        Same per-batch block chunking as the built-in lattice, but the per-primary
        position comes from the user's ``warp_beamlet_sampler`` (wrapped by
        :func:`~pyradmc.backends.warp.kernels.make_beamlet_generator_kernel`) instead of
        analytic bounds; the wrapper books emitted energy into ``emitted``.
        """
        t = 0
        while t < block_histories:
            n_chunk = min(chunk, block_histories - t)
            for q in queues:
                _reset_count(q, device, stream)
            _launch(
                generator,
                n_chunk,
                [
                    seed,
                    group_start,
                    n_per,
                    per_batch,
                    batch,
                    t,
                    1 if correlated else 0,
                    slots,
                    emitted,
                    queues[0],
                ],
                device,
                stream,
            )
            _set_count(queues[0], n_chunk, device, stream)
            self._drain_queues(
                queues[0],
                queues[1],
                queues[2],
                queues[3],
                gi,
                si,
                density,
                material,
                tab,
                slots,
                edep,
                escaped,
                unscored,
                deposited,
                violations,
                pcut,
                ecut,
                step_energy_fraction,
                deposit_resolution,
                msc_model_gs,
                transport_electrons,
                dose_to_water,
                device,
                stream,
                staging,
            )
            t += n_chunk

    def _generate_group_presampled(
        self,
        source,
        group_start,
        group,
        seed,
        n_per,
        per_batch,
        batch,
        correlated,
        chunk,
        gi,
        si,
        density,
        material,
        tab,
        queues,
        slots,
        edep,
        escaped,
        unscored,
        deposited,
        violations,
        pcut,
        ecut,
        step_energy_fraction,
        deposit_resolution,
        msc_model_gs,
        transport_electrons,
        dose_to_water,
        device,
        stream=None,
        staging=None,
    ) -> float:
        """Host pre-sample one batch of each beamlet's primaries and upload them.

        For beamlet ``j`` the within-beamlet index ``r = batch*per_batch + i`` keys the
        transport RNG on ``r`` (correlated) or ``h = j*n_per + r`` (independent) — the
        mapping ``ReferenceEngine.run_dij`` defines — and the column tag is ``local``,
        the batch being separated in time instead (see ``accumulate_dij_batch``), so the
        result is bit-invariant to grouping/chunking. Only this batch's ``per_batch``
        primaries are sampled, at history offset ``offset + batch*per_batch``:
        ``_presample`` keys each history on ``init_state(seed, history_offset + i)``, so
        a per-batch sub-range yields exactly the primaries the full-range sample would
        have put at those indices. Every beamlet primary is a photon at the sampled
        energy/position carrying the sampled statistical weight (the collimated sources
        attenuate by weight), and the emitted book sums ``weight * energy``, matching
        the reference Dij. Returns this batch's emitted energy for the group.
        """
        emitted = 0.0
        for local in range(group):
            beamlet = group_start + local
            offset = (0 if correlated else beamlet * n_per) + batch * per_batch
            cols = source.sample_beamlet_batch(seed, offset, per_batch, beamlet)
            emitted += float(
                np.sum(cols["weight"].astype(np.float64) * cols["energy"].astype(np.float64))
            )
            i = np.arange(per_batch, dtype=np.int64)
            key = (offset + i).astype(np.int32)
            tag = np.full(per_batch, local, dtype=np.int32)
            r0 = 0
            while r0 < per_batch:
                r1 = min(r0 + chunk, per_batch)
                nc = r1 - r0
                sub = slice(r0, r1)
                sub_batch = {
                    name: cols[name][sub]
                    for name in ("energy", "x", "y", "z", "ux", "uy", "uz", "weight")
                }
                for q in queues:
                    _reset_count(q, device, stream)
                self._seed_queue(
                    queues[0],
                    seed,
                    key[sub],
                    tag[sub],
                    np.full(nc, PHOTON, dtype=np.int32),
                    sub_batch,
                    np.ones(nc, dtype=bool),
                    device,
                    stream,
                )
                self._drain_queues(
                    queues[0],
                    queues[1],
                    queues[2],
                    queues[3],
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    device,
                    stream,
                    staging,
                )
                r0 = r1
        return emitted

    def _upload_grid(self, device: str):
        gi = GridInfo()
        gi.x_lo, gi.y_lo, gi.z_lo = self.grid.origin
        gi.x_hi, gi.y_hi, gi.z_hi = self.grid.upper_corner
        gi.sx, gi.sy, gi.sz = self.grid.spacing
        gi.nx = self.grid.shape[0]
        gi.ny = self.grid.shape[1]
        gi.nz = self.grid.shape[2]
        gi.n_voxels = int(np.prod(self.grid.shape))
        gi.min_spacing = min(self.grid.spacing)
        # The transport grid is always rectilinear (AGENTS.md 6); these fields exist
        # only because scoring shares the struct, and no kernel reads them at mode 0.
        gi.mode = 0
        gi.axis_x = gi.axis_y = 0.0
        gi.n_shells = 0
        gi.r_inner_sq = gi.r_outer_sq = 0.0
        gi.r_edges_sq = _no_shells(device)
        gi.z_edges = _no_shells(device)
        density = wp.array(self.grid.density.astype(np.float32), dtype=float, device=device)
        # Material indices travel as uint8: 4x narrower than int32 on the transport
        # kernels' random per-step load, and lossless for any registry the tables can
        # hold. astype would silently *truncate* an index above 255 into a different
        # valid-looking material, so an out-of-range source is refused here, before
        # anything is transported.
        if self.cross_sections.n_materials > 256:
            raise ValueError(
                f"the Warp backend addresses at most 256 materials (uint8 voxel map); "
                f"this source declares {self.cross_sections.n_materials}"
            )
        material = wp.array(self.grid.material.astype(np.uint8), dtype=wp.uint8, device=device)
        return gi, density, material

    def _upload_tables(
        self, e_max: float, pcut: float, ecut: float, device: str, with_gs: bool = False
    ) -> Tables:
        key = (ecut, pcut, e_max)
        with self._table_cache_lock:
            host = self._table_cache.get(key)
            if host is None:
                host = self.cross_sections.build_tables(
                    ecut=ecut, pcut=pcut, e_max=e_max * _E_MAX_MARGIN
                )
                self._table_cache[key] = host
        tab = Tables()
        for table_name in (
            "mu_compton",
            "mu_photo",
            "mu_pair",
            "mu_rayleigh",
            "majorant",
            "stopping_restricted",
            "stopping_radiative",
            "moller",
            "csda_range",
            "scattering_power",
            "log_eta",
            "restricted_range",
            "energy_of_restricted_range",
        ):
            setattr(
                tab,
                table_name,
                wp.array(getattr(host, table_name).astype(np.float32), dtype=float, device=device),
            )
        tab.coherent_x = wp.array(host.coherent_x.astype(np.float32), dtype=float, device=device)
        tab.coherent_cumulative = wp.array(
            host.coherent_cumulative.astype(np.float32), dtype=float, device=device
        )
        tab.p_log_e_min = host.photon_log_e_min
        tab.p_inv_dlog = host.photon_inv_dlog
        tab.e_log_e_min = host.electron_log_e_min
        tab.e_inv_dlog = host.electron_inv_dlog
        tab.r_log_min = host.range_log_r_min
        tab.r_inv_dlog = host.range_inv_dlog
        tab.n_points = host.n_points
        tab.n_coherent = host.n_coherent
        if with_gs:
            gs_host = self._gs_grid_host(host, ecut, e_max)
            tab.gs_values = wp.array(gs_host.values.astype(np.float32), dtype=float, device=device)
            tab.gs_ix0 = gs_host.ix0
            tab.gs_iy0 = gs_host.iy0
            tab.gs_n_eta = gs_host.values.shape[0]
            tab.gs_n_theta2 = gs_host.values.shape[1]
            tab.gs_n_u = gs_host.n_u
        else:
            # Placeholder the kernel never reads: the branch is on the launch
            # flag, but a wp.struct must carry a valid array on every field.
            tab.gs_values = wp.zeros((1, 1, 1), dtype=float, device=device)
            tab.gs_ix0 = 0
            tab.gs_iy0 = 0
            tab.gs_n_eta = 1
            tab.gs_n_theta2 = 1
            tab.gs_n_u = 1
        return tab

    def _gs_grid_host(self, host, ecut: float, e_max: float):
        """Return the eager Goudsmit-Saunderson grid for these tables, built once.

        Built host-side **before any launch** — never lazily mid-transport (an
        86 s stall, measured) — and cached under the engine's table lock, so
        concurrent ``devices=[...]`` shard threads share one immutable build
        (the thread-safety hazard a lazily growing per-source dict had). The
        window covers exactly the keys the kernel's lookups can produce for
        the materials present in this engine's voxel map; the fixed build seed
        makes every node bit-identical to the reference backend's lazy cache.
        """
        from pyradmc.data.goudsmit_saunderson import build_gs_grid, gs_window_from_tables

        key = ("gs", ecut, e_max)
        with self._table_cache_lock:
            gs_host = self._table_cache.get(key)
            if gs_host is None:
                present = np.unique(self.grid.material).astype(int).tolist()
                gs_host = build_gs_grid(*gs_window_from_tables(host, present))
                self._table_cache[key] = gs_host
        return gs_host

run

run(source: Source, n_histories: int, n_batches: int, seed: int, pcut: float = PCUT_MEV, ecut: float = ECUT_MEV, transport_electrons: bool = True, primary_kind: str = 'photon', scoring_grid: ScoringGeometry | None = None, scoring_mode: str = 'dose_to_medium', step_energy_fraction: float | None = None, deposit_resolution_cm: float | None = None, msc_model: str = 'gs', progress: ProgressCallback | None = None, concurrent_batches: int = 1) -> TransportResult

Transport n_histories primaries; same contract as the reference engine.

See :meth:pyradmc.backends.ref.engine.ReferenceEngine.run for parameter semantics — the two signatures are deliberately identical (scoring_grid included: the scoring geometry deposits accumulate on — a rectilinear :class:~pyradmc.scoring.grid.ScoringGrid, default the transport grid, or a :class:~pyradmc.scoring.cylinder.CylindricalScoringGrid for a pencil-beam kernel — with off-geometry deposits booked to energy_unscored; and scoring_mode: dose-to-water weights each deposit in-kernel by the stopping-power ratio from the flattened tables while the books stay physical; progress: one tick per completed batch, the identical cadence to the reference engine — see :mod:pyradmc.progress). A source with an in-kernel generator is generated on-device: the built-in mono beams from analytic parameters (all of one primary_kind) and the exact :class:~pyradmc.geometry.source.SpectralBeamSource type from its uploaded spectrum tables (photons; primary_kind ignored). Any other source (a phase space, a spectral subclass, or a user :class:~pyradmc.geometry.source.Source) is transported by host-sampling each chunk via sample_batch and seeding the photon and electron queues by the per-record kind; primary_kind is then ignored and energy_emitted is booked from the sampled records.

concurrent_batches overlaps whole statistical batches on independent CUDA streams, with private queues, RNG slots, and fixed-point dose maps. The float64 batch-dose fold remains serialized in batch order, so changing the lane count is bitwise inert on one device. CPU accepts the same option but runs sequentially. Each lane requires another queue set and dose map; memory use therefore grows approximately linearly with the lane count.

msc_model selects the multiple-scattering law exactly as in the reference loop (see :func:pyradmc.transport.electron.electron_steps): "gs" — the shipped default — samples Goudsmit-Saunderson deflections from an eagerly precomputed table grid (built host-side on the first GS run for this (ecut, e_max), persisted to the user cache, uploaded per run) and does not apply the Gaussian-validity angular cap; the "gaussian" hinge survives as the paired-comparison test instrument. step_energy_fraction=None resolves to the selected model's validated fraction.

Source code in pyradmc/backends/warp/engine.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
def run(
    self,
    source: Source,
    n_histories: int,
    n_batches: int,
    seed: int,
    pcut: float = PCUT_MEV,
    ecut: float = ECUT_MEV,
    transport_electrons: bool = True,
    primary_kind: str = "photon",
    scoring_grid: ScoringGeometry | None = None,
    scoring_mode: str = "dose_to_medium",
    step_energy_fraction: float | None = None,
    deposit_resolution_cm: float | None = None,
    msc_model: str = "gs",
    progress: ProgressCallback | None = None,
    concurrent_batches: int = 1,
) -> TransportResult:
    """Transport ``n_histories`` primaries; same contract as the reference engine.

    See :meth:`pyradmc.backends.ref.engine.ReferenceEngine.run` for parameter
    semantics — the two signatures are deliberately identical (``scoring_grid``
    included: the scoring geometry deposits accumulate on — a rectilinear
    :class:`~pyradmc.scoring.grid.ScoringGrid`, default the transport grid, or a
    :class:`~pyradmc.scoring.cylinder.CylindricalScoringGrid` for a pencil-beam
    kernel — with off-geometry deposits booked to ``energy_unscored``; and ``scoring_mode``:
    dose-to-water weights each deposit in-kernel by the stopping-power ratio
    from the flattened tables while the books stay physical; ``progress``: one
    tick per completed batch, the identical cadence to the reference engine —
    see :mod:`pyradmc.progress`). A source with an in-kernel generator is
    generated on-device: the built-in mono beams from analytic parameters (all
    of one ``primary_kind``) and the exact
    :class:`~pyradmc.geometry.source.SpectralBeamSource` type from its uploaded
    spectrum tables (photons; ``primary_kind`` ignored). Any other source (a
    phase space, a spectral *subclass*, or a user
    :class:`~pyradmc.geometry.source.Source`) is transported by host-sampling
    each chunk via ``sample_batch`` and seeding the photon and electron queues
    by the per-record kind; ``primary_kind`` is then ignored and
    ``energy_emitted`` is booked from the sampled records.

    ``concurrent_batches`` overlaps whole statistical batches on independent
    CUDA streams, with private queues, RNG slots, and fixed-point dose maps.
    The float64 batch-dose fold remains serialized in batch order, so changing
    the lane count is bitwise inert on one device. CPU accepts the same option
    but runs sequentially. Each lane requires another queue set and dose map;
    memory use therefore grows approximately linearly with the lane count.

    ``msc_model`` selects the multiple-scattering law exactly as in the
    reference loop (see
    :func:`pyradmc.transport.electron.electron_steps`): ``"gs"`` — the
    shipped default — samples Goudsmit-Saunderson deflections from an
    eagerly precomputed table grid (built host-side on the first GS run
    for this ``(ecut, e_max)``, persisted to the user cache, uploaded per
    run) and does not apply the Gaussian-validity angular cap; the
    ``"gaussian"`` hinge survives as the paired-comparison test
    instrument. ``step_energy_fraction=None`` resolves to the selected
    model's validated fraction.
    """
    if n_histories < 1:
        raise ValueError(f"need at least one history, got {n_histories}")
    if concurrent_batches < 1:
        raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
    if n_histories % n_batches != 0:
        raise ValueError(
            f"n_histories={n_histories} not divisible by n_batches={n_batches}; "
            "unequal batches would weight batch means inconsistently"
        )
    from pyradmc.backends.warp.presolve import DevicePhaseSpace

    in_kernel = isinstance(source, PencilBeamSource | ParallelBeamSource)
    is_device_ps = isinstance(source, DevicePhaseSpace)
    # Exact type, not isinstance: a subclass may override emit/sample_batch, and
    # the built-in generator would silently bypass the override; a subclass keeps
    # the host pre-sampling route (test-pinned).
    is_spectral = type(source) is SpectralBeamSource
    is_spectral_mask = (
        type(source) is TransmissionMaskSource and type(source.inner) is SpectralBeamSource
    )
    if is_device_ps and source.device != self.device:
        raise ValueError(
            f"the device pre-solve buffer is on {source.device!r} but this engine "
            f"runs on {self.device!r}; pre-solve on the engine's device"
        )
    if in_kernel:
        if primary_kind not in ("photon", "electron"):
            raise ValueError(f"unknown primary_kind {primary_kind!r}")
        kind = PHOTON if primary_kind == "photon" else ELECTRON

    dose_to_water = 1 if validate_scoring_mode(scoring_mode, transport_electrons) else 0
    msc_model_gs = _validate_msc_model(msc_model)
    if step_energy_fraction is None:
        step_energy_fraction = default_step_energy_fraction(msc_model)
    if deposit_resolution_cm is not None and deposit_resolution_cm <= 0.0:
        raise ValueError(f"deposit resolution must be positive, got {deposit_resolution_cm}")
    # One float travels to the kernels; non-positive is the single-midpoint
    # sentinel, matching pyradmc.transport.electron.substep_pieces.
    deposit_resolution = 0.0 if deposit_resolution_cm is None else deposit_resolution_cm

    device = self.device
    gi, density, material = self._upload_grid(device)
    scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
    si = _scoring_info(scoring, device)
    table_energy = source.max_energy
    tab = self._upload_tables(table_energy, pcut, ecut, device, with_gs=msc_model_gs == 1)

    chunk = min(self._chunk_histories(device), n_histories)
    capacity = chunk * self.queue_factor
    lanes = min(concurrent_batches, n_batches) if wp.get_device(device).is_cuda else 1
    lane_resources = [
        _LaneResources(
            stream=wp.Stream(device) if lanes > 1 else None,
            staging=(
                wp.zeros(1, dtype=wp.int32, device="cpu", pinned=True) if lanes > 1 else None
            ),
            queues=[_upload_queue(capacity, device) for _ in range(4)],
            slots=wp.zeros(capacity, dtype=wp.uint32, device=device),
            edep=wp.zeros(scoring.n_voxels, dtype=wp.int64, device=device),
        )
        for _ in range(lanes)
    ]
    queues = lane_resources[0].queues
    slots = lane_resources[0].slots
    edep = lane_resources[0].edep
    fold_stream = wp.Stream(device) if lanes > 1 else None
    escaped = wp.zeros(1, dtype=wp.int64, device=device)
    unscored = wp.zeros(1, dtype=wp.int64, device=device)
    deposited = wp.zeros(1, dtype=wp.int64, device=device)
    violations = wp.zeros(1, dtype=wp.int32, device=device)

    # Advanced route: a source exposing a warp_sampler is generated in-kernel by a
    # wrapper kernel (built once per sampler), which books emitted weight-energy
    # into this cumulative counter (mono beams book analytically; a pre-sampled
    # source books per chunk).
    generator = None
    emitted = None
    wraps = not in_kernel and not is_device_ps and not is_spectral and not is_spectral_mask
    if wraps and source.warp_sampler is not None:
        generator = kernels.make_generator_kernel(source.warp_sampler)
        emitted = wp.zeros(1, dtype=wp.int64, device=device)
    if is_device_ps:
        # The seeding kernel books emitted weight-energy into this counter,
        # like the wrapped-generator route, read once after the batches.
        emitted = wp.zeros(1, dtype=wp.int64, device=device)
    spectral_tables = None
    mask_table = None
    if is_spectral or is_spectral_mask:
        # Built-in in-kernel generation for the exact spectral type: the spectrum
        # inversion tables upload once per run and each chunk samples on-device
        # (the host pre-sampling was measured wall-dominant on CT-grade runs).
        # Emitted energy is booked per primary, like the wrapped-generator route.
        spectral_source = source.inner if is_spectral_mask else source
        spectrum = spectral_source.spectrum
        spectral_tables = (
            wp.array(spectrum.edges.astype(np.float32), dtype=float, device=device),
            wp.array(spectrum.cdf.astype(np.float32), dtype=float, device=device),
        )
        emitted = wp.zeros(1, dtype=wp.int64, device=device)
        if is_spectral_mask:
            mask_table = wp.array(
                source.mask.reshape(-1).astype(np.float32), dtype=float, device=device
            )

    n_voxels = scoring.n_voxels
    voxel_mass = wp.array(scoring.voxel_mass.reshape(n_voxels), dtype=wp.float64, device=device)
    # Dose finalize on the device: per-voxel batch sums of dose and dose^2, folded
    # in batch order so the open-field reduction matches a 1x1 Dij column bitwise
    # on one device (kernels.accumulate_run_batch). Only the reduced maps read
    # back, not the dense per-batch fixed-point buffer.
    s1 = wp.zeros(n_voxels, dtype=wp.float64, device=device)
    s2 = wp.zeros(n_voxels, dtype=wp.float64, device=device)
    dep_total = wp.zeros(1, dtype=wp.int64, device=device)  # dose-to-medium quanta
    per_batch = n_histories // n_batches
    energy_escaped = 0.0
    energy_unscored = 0.0
    energy_deposited = 0.0  # dose-to-water physical book (summed per-batch counter)
    energy_emitted = 0.0
    escaped_quanta = 0
    unscored_quanta = 0
    deposited_quanta = 0
    emitter = ProgressEmitter(progress, n_histories)

    if lanes > 1:
        # Shared counters use integer atomics and therefore need only one clear
        # for the whole concurrent run. Each lane owns the state whose writes
        # are not commutative (queues, RNG slots, and its batch dose map).
        escaped.zero_()
        unscored.zero_()
        deposited.zero_()
        violations.zero_()
        energy_emitted += self._run_forward_batch_lanes(
            lane_resources,
            fold_stream,
            n_batches,
            per_batch,
            chunk,
            source=source,
            kind=kind if in_kernel else None,
            in_kernel=in_kernel,
            is_device_ps=is_device_ps,
            is_spectral=is_spectral,
            is_spectral_mask=is_spectral_mask,
            generator=generator,
            spectral_tables=spectral_tables,
            mask_table=mask_table,
            seed=seed,
            gi=gi,
            si=si,
            density=density,
            material=material,
            tab=tab,
            escaped=escaped,
            unscored=unscored,
            deposited=deposited,
            violations=violations,
            pcut=pcut,
            ecut=ecut,
            step_energy_fraction=step_energy_fraction,
            deposit_resolution=deposit_resolution,
            msc_model_gs=msc_model_gs,
            transport_electrons=transport_electrons,
            dose_to_water=dose_to_water,
            emitted=emitted,
            device=device,
            voxel_mass=voxel_mass,
            n_voxels=n_voxels,
            s1=s1,
            s2=s2,
            dep_total=dep_total,
            emitter=emitter,
        )
        wp.synchronize_device(device)
        if int(violations.numpy()[0]) != 0:
            raise RuntimeError(
                "Woodcock majorant violated in the kernel despite table headroom. "
                "The geometry contains material or density the majorant "
                "declaration did not cover."
            )
        escaped_quanta = int(escaped.numpy()[0])
        unscored_quanta = int(unscored.numpy()[0])
        if dose_to_water != 0:
            deposited_quanta = int(deposited.numpy()[0])

    history = 0
    for _ in range(n_batches if lanes == 1 else 0):
        edep.zero_()
        escaped.zero_()
        unscored.zero_()
        deposited.zero_()
        remaining = per_batch
        while remaining > 0:
            n_chunk = min(chunk, remaining)
            if in_kernel:
                self._transport_chunk(
                    source,
                    kind,
                    seed,
                    history,
                    n_chunk,
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    queues,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    device,
                )
            elif is_device_ps:
                self._transport_chunk_device_buffer(
                    source,
                    seed,
                    history,
                    n_chunk,
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    queues,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    emitted,
                    device,
                )
            elif is_spectral_mask:
                self._transport_chunk_spectral_mask(
                    source,
                    spectral_tables,
                    mask_table,
                    seed,
                    history,
                    n_chunk,
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    queues,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    emitted,
                    device,
                )
            elif is_spectral:
                self._transport_chunk_spectral(
                    source,
                    spectral_tables,
                    seed,
                    history,
                    n_chunk,
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    queues,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    emitted,
                    device,
                )
            elif generator is not None:
                self._transport_chunk_wrapped(
                    generator,
                    seed,
                    history,
                    n_chunk,
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    queues,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    emitted,
                    device,
                )
            else:
                energy_emitted += self._transport_chunk_presampled(
                    source,
                    seed,
                    history,
                    n_chunk,
                    gi,
                    si,
                    density,
                    material,
                    tab,
                    queues,
                    slots,
                    edep,
                    escaped,
                    unscored,
                    deposited,
                    violations,
                    pcut,
                    ecut,
                    step_energy_fraction,
                    deposit_resolution,
                    msc_model_gs,
                    transport_electrons,
                    dose_to_water,
                    device,
                )
            history += n_chunk
            remaining -= n_chunk
        wp.synchronize_device(device)
        if int(violations.numpy()[0]) != 0:
            raise RuntimeError(
                "Woodcock majorant violated in the kernel despite table headroom. "
                "The geometry contains material or density the majorant "
                "declaration did not cover."
            )
        # Fold this batch into the running dose sums on the device; the dense
        # fixed-point map never leaves the GPU. dep_total sums the physical
        # quanta (dose-to-medium book); dose-to-water reads its own counter.
        wp.launch(
            kernels.accumulate_run_batch,
            dim=n_voxels,
            inputs=[
                edep,
                voxel_mass,
                float(per_batch),
                float(ENERGY_QUANTUM_MEV),
                s1,
                s2,
                dep_total,
            ],
            device=device,
        )
        escaped_quanta += int(escaped.numpy()[0])
        unscored_quanta += int(unscored.numpy()[0])
        if dose_to_water != 0:
            deposited_quanta += int(deposited.numpy()[0])
        emitter.tick(per_batch)

    # Convert the exact integer books once, after aggregation. Besides avoiding
    # batch-count-dependent float rounding, this matches the Dij energy fold and
    # keeps concurrent lane scheduling bitwise inert.
    energy_escaped = float(escaped_quanta) * ENERGY_QUANTUM_MEV
    energy_unscored = float(unscored_quanta) * ENERGY_QUANTUM_MEV
    if dose_to_water != 0:
        energy_deposited = float(deposited_quanta) * ENERGY_QUANTUM_MEV

    if in_kernel:
        # Mono beams: every primary is one unit-weight photon at the beam energy
        # (max_energy == energy), so the device need not report emitted energy.
        energy_emitted = n_histories * source.max_energy
    elif emitted is not None:
        energy_emitted = float(emitted.numpy()[0]) * ENERGY_QUANTUM_MEV

    mean_dev = wp.zeros(n_voxels, dtype=wp.float64, device=device)
    sigma_dev = wp.zeros(n_voxels, dtype=wp.float64, device=device)
    wp.launch(
        kernels.finalize_run,
        dim=n_voxels,
        inputs=[s1, s2, n_batches, mean_dev, sigma_dev],
        device=device,
    )
    wp.synchronize_device(device)
    if dose_to_water == 0:
        energy_deposited = float(dep_total.numpy()[0]) * ENERGY_QUANTUM_MEV
    return TransportResult(
        dose=mean_dev.numpy().reshape(scoring.shape),
        dose_sigma=sigma_dev.numpy().reshape(scoring.shape),
        energy_emitted=energy_emitted,
        energy_deposited=energy_deposited,
        energy_escaped=energy_escaped,
        energy_unscored=energy_unscored,
        n_histories=n_histories,
        n_batches=n_batches,
        scoring_mode=scoring_mode,
        provenance=self._provenance(
            seed, pcut, ecut, msc_model, step_energy_fraction, deposit_resolution_cm
        ),
    )

run_dij

run_dij(source: BeamletSource, n_histories_per_beamlet: int, n_batches: int, seed: int, pcut: float = PCUT_MEV, ecut: float = ECUT_MEV, transport_electrons: bool = True, truncation: float = DIJ_TRUNCATION_RELATIVE, correlated: bool = True, beamlet_group_size: int | None = None, scoring_grid: ScoringGrid | None = None, scoring_mode: str = 'dose_to_medium', step_energy_fraction: float | None = None, deposit_resolution_cm: float | None = None, msc_model: str = 'gs', devices: Sequence[str] | None = None, concurrent_batches: int = 1, progress: ProgressCallback | None = None) -> DijResult

Compute the Dij over the lattice; same contract as the reference engine.

See :meth:pyradmc.backends.ref.engine.ReferenceEngine.run_dij for the history-to-beamlet mapping and parameter semantics, correlated (the shipped sampling configuration, default True; False is the test instrument) included — the signatures are deliberately identical up to the one scheduling knob:

Parameters:

Name Type Description Default
beamlet_group_size int | None

Beamlets scored concurrently into one dense device buffer. Purely a memory/occupancy trade-off: streams are pure functions of (seed, history) and scoring is associative, so the result is bit-identical for any value (test-pinned), exactly like chunk_size.

Default None auto-sizes it from the reported free memory of the (each) device — the largest group whose dense maps fit half of it, clamped to [1, min(n_beamlets, 1024)], the minimum over a multi-device set, and 128 where free memory is unknown (cpu). An explicit integer is used exactly as given.

This is the only dial for the dense device cost, which is group * n_voxels * 24 bytes — one int64 quanta map plus the two float64 sums — and is independent of n_batches: batches are streamed one drain at a time and folded into the sums, not stored along a device axis. It is also what sets the launch width, since a batch's block is group * n_histories_per_beamlet / n_batches histories; raise it until that block covers chunk_size, memory permitting. On a CT-resolution scoring grid the auto-size shrinks the group to fit (pass an explicit value to override) — or coarsen scoring_grid, which shrinks the dense cost cubically.

None
devices Sequence[str] | None

Devices to shard the beamlet groups over, one host thread each; default (None) runs everything on the engine's own device. Groups are independent — no cross-device reduction — so this scales with the device count rather than trading anything away, and each device pays the full per-device footprint (geometry, tables, queues, and the dense group * n_voxels * 24 maps) since nothing is shared.

Scheduling is greedy from a shared ordered queue: a device pulls the next group the moment it is free, so an idle device always receives new work before any deeper concurrency (concurrent_batches lanes) on a busy one, and a slow device in a mixed set self-limits to the groups it can finish instead of holding an equal share hostage.

Reproducibility: every column is computed wholly on one device, so scheduling never changes what a device computes for a beamlet — only which device computes it (test-pinned). Over identical devices the Dij is therefore bit-identical however the pulls interleave. Over a heterogeneous set (e.g. ["cuda:0", "cpu"]) the column-to-device assignment is timing-dependent run to run — columns stay statistically equivalent, the cpu/cuda relationship AGENTS.md 2.3 defines, but the same run twice may place them differently; pass a single device where strict run-to-run bit reproducibility matters on mixed hardware. The energy books stay exact either way, being integer quanta summed in device-list order.

None
concurrent_batches int

Batch lanes per CUDA device: up to this many batches of the current group transport concurrently, each lane on its own stream with its own queues, RNG slots, and quanta map. Bitwise inert (test-pinned): the float64 fold of batch sums is serialized in batch order across lanes, so any value reproduces the sequential Dij exactly — the knob only trades memory for overlap, like chunk_size. Extra lanes cost their queues and one group * n_voxels int64 map each; a cpu device has no streams and ignores the setting. On this engine's development hardware (laptop RTX 4070) the drain gaps lanes can hide measured at 2-3% of Dij wall time — the knob exists for larger cards, where the balance may differ; measure before defaulting it on.

1
scoring_grid ScoringGrid | None

Semantics as in :meth:run, and the memory lever here: the n_voxels above is the scoring voxel count, so a coarser dose grid shrinks the per-group device buffer and the sparse Dij cubically while transport keeps the full CT resolution.

None
scoring_mode str

Weights the column tallies as in :meth:run; the energy books stay physical in either mode.

'dose_to_medium'
msc_model str

As in :meth:run. The GS grid is built once, before any launch, under the same lock every device shard takes.

'gs'
progress ProgressCallback | None

Optional callback, as in :meth:run (see :mod:pyradmc.progress). Ticks once per beamlet group completed (ceil(n_beamlets / beamlet_group_size) ticks total), each covering that group's full group_size * n_histories_per_beamlet histories across all of its batches. A tick marks the group finished — transported, reduced, truncated and read back — not merely transported, so rate_hz describes the group it names and the last tick coincides with the method returning — a different axis from the reference engine's per-batch ticks, since this engine schedules groups off a shared queue rather than iterating batches outermost. Both reach the same total; treat histories_done / histories_total as the portable signal, not tick count or spacing. One :class:~pyradmc.progress.ProgressEmitter is shared across every device shard and, within a shard, is unaffected by concurrent_batches (only the shard's outer per-group point ticks): the emitter's own lock serializes ticks arriving from multiple device threads, so the callback is thread-safe without any extra care on the caller's part.

None
Source code in pyradmc/backends/warp/engine.py
def run_dij(
    self,
    source: BeamletSource,
    n_histories_per_beamlet: int,
    n_batches: int,
    seed: int,
    pcut: float = PCUT_MEV,
    ecut: float = ECUT_MEV,
    transport_electrons: bool = True,
    truncation: float = DIJ_TRUNCATION_RELATIVE,
    correlated: bool = True,
    beamlet_group_size: int | None = None,
    scoring_grid: ScoringGrid | None = None,
    scoring_mode: str = "dose_to_medium",
    step_energy_fraction: float | None = None,
    deposit_resolution_cm: float | None = None,
    msc_model: str = "gs",
    devices: Sequence[str] | None = None,
    concurrent_batches: int = 1,
    progress: ProgressCallback | None = None,
) -> DijResult:
    """Compute the Dij over the lattice; same contract as the reference engine.

    See :meth:`pyradmc.backends.ref.engine.ReferenceEngine.run_dij` for the
    history-to-beamlet mapping and parameter semantics, ``correlated``
    (the shipped sampling configuration, default True; ``False`` is the
    test instrument) included — the signatures are deliberately identical
    up to the one scheduling knob:

    Parameters
    ----------
    beamlet_group_size
        Beamlets scored concurrently into one dense device buffer. Purely a
        memory/occupancy trade-off: streams are pure functions of
        ``(seed, history)`` and scoring is associative, so the result is
        bit-identical for any value (test-pinned), exactly like ``chunk_size``.

        Default ``None`` auto-sizes it from the *reported free* memory of the
        (each) device — the largest group whose dense maps fit half of it,
        clamped to ``[1, min(n_beamlets, 1024)]``, the minimum over a
        multi-device set, and 128 where free memory is unknown (cpu). An
        explicit integer is used exactly as given.

        This is the *only* dial for the dense device cost, which is
        ``group * n_voxels * 24`` bytes — one int64 quanta map plus the two
        float64 sums — and is **independent of** ``n_batches``: batches are
        streamed one drain at a time and folded into the sums, not stored
        along a device axis. It is also what sets the launch width, since a
        batch's block is ``group * n_histories_per_beamlet / n_batches``
        histories; raise it until that block covers ``chunk_size``, memory
        permitting. On a CT-resolution scoring grid the auto-size shrinks
        the group to fit (pass an explicit value to override) — or coarsen
        ``scoring_grid``, which shrinks the dense cost cubically.

    devices
        Devices to shard the beamlet groups over, one host thread each; default
        (``None``) runs everything on the engine's own ``device``. Groups are
        independent — no cross-device reduction — so this scales with the device
        count rather than trading anything away, and each device pays the full
        per-device footprint (geometry, tables, queues, and the dense
        ``group * n_voxels * 24`` maps) since nothing is shared.

        Scheduling is greedy from a shared ordered queue: a device pulls the
        next group the moment it is free, so an idle device always receives new
        work before any deeper concurrency (``concurrent_batches`` lanes) on a
        busy one, and a slow device in a mixed set self-limits to the groups it
        can finish instead of holding an equal share hostage.

        Reproducibility: every column is computed wholly on one device, so
        scheduling never changes *what* a device computes for a beamlet — only
        which device computes it (test-pinned). Over identical devices the Dij
        is therefore bit-identical however the pulls interleave. Over a
        *heterogeneous* set (e.g. ``["cuda:0", "cpu"]``) the column-to-device
        assignment is timing-dependent run to run — columns stay statistically
        equivalent, the cpu/cuda relationship AGENTS.md 2.3 defines, but the
        same run twice may place them differently; pass a single device where
        strict run-to-run bit reproducibility matters on mixed hardware. The
        energy books stay exact either way, being integer quanta summed in
        device-list order.
    concurrent_batches
        Batch lanes per CUDA device: up to this many batches of the current
        group transport concurrently, each lane on its own stream with its own
        queues, RNG slots, and quanta map. **Bitwise inert** (test-pinned): the
        float64 fold of batch sums is serialized in batch order across lanes,
        so any value reproduces the sequential Dij exactly — the knob only
        trades memory for overlap, like ``chunk_size``. Extra lanes cost their
        queues and one ``group * n_voxels`` int64 map each; a cpu device has no
        streams and ignores the setting. On this engine's development hardware
        (laptop RTX 4070) the drain gaps lanes can hide measured at 2-3% of
        Dij wall time — the knob exists for larger cards, where the balance
        may differ; measure before defaulting it on.

    scoring_grid
        Semantics as in :meth:`run`, and the memory lever here: the
        ``n_voxels`` above is the *scoring* voxel count, so a coarser dose
        grid shrinks the per-group device buffer and the sparse Dij cubically
        while transport keeps the full CT resolution.
    scoring_mode
        Weights the column tallies as in :meth:`run`; the energy books stay
        physical in either mode.
    msc_model
        As in :meth:`run`. The GS grid is built once, before any launch, under
        the same lock every device shard takes.
    progress
        Optional callback, as in :meth:`run` (see :mod:`pyradmc.progress`).
        Ticks once per **beamlet group** *completed* (``ceil(n_beamlets /
        beamlet_group_size)`` ticks total), each covering that group's full
        ``group_size * n_histories_per_beamlet`` histories across all of its
        batches. A tick marks the group finished — transported, reduced,
        truncated and read back — not merely transported, so ``rate_hz``
        describes the group it names and the last tick coincides with the
        method returning — a different axis from the reference engine's per-batch
        ticks, since this engine schedules groups off a shared queue rather
        than iterating batches outermost. Both reach the same total; treat
        ``histories_done / histories_total`` as the portable signal, not tick
        count or spacing. One :class:`~pyradmc.progress.ProgressEmitter` is
        shared across every device shard and, within a shard, is unaffected
        by ``concurrent_batches`` (only the shard's outer per-group point
        ticks): the emitter's own lock serializes ticks arriving from
        multiple device threads, so the callback is thread-safe without any
        extra care on the caller's part.
    """
    if n_histories_per_beamlet < 1:
        raise ValueError(
            f"need at least one history per beamlet, got {n_histories_per_beamlet}"
        )
    if n_histories_per_beamlet % n_batches != 0:
        raise ValueError(
            f"n_histories_per_beamlet={n_histories_per_beamlet} not divisible by "
            f"n_batches={n_batches}; unequal batches would weight batch means inconsistently"
        )
    if beamlet_group_size is not None and beamlet_group_size < 1:
        raise ValueError(f"need a positive beamlet group size, got {beamlet_group_size}")
    if concurrent_batches < 1:
        raise ValueError(f"need at least one lane, got concurrent_batches={concurrent_batches}")
    dose_to_water = 1 if validate_scoring_mode(scoring_mode, transport_electrons) else 0
    msc_model_gs = _validate_msc_model(msc_model)
    if step_energy_fraction is None:
        step_energy_fraction = default_step_energy_fraction(msc_model)
    if deposit_resolution_cm is not None and deposit_resolution_cm <= 0.0:
        raise ValueError(f"deposit resolution must be positive, got {deposit_resolution_cm}")
    # One float travels to the kernels; non-positive is the single-midpoint
    # sentinel, matching pyradmc.transport.electron.substep_pieces.
    deposit_resolution = 0.0 if deposit_resolution_cm is None else deposit_resolution_cm

    scoring = scoring_grid if scoring_grid is not None else ScoringGrid.for_grid(self.grid)
    n_beamlets = source.n_beamlets
    per_batch = n_histories_per_beamlet // n_batches

    device_list = [self.device] if devices is None else list(dict.fromkeys(devices))
    if not device_list:
        raise ValueError("devices must name at least one device")

    if beamlet_group_size is None:
        # One shared size for every device: group_starts partitions the beamlet
        # axis once, so a multi-device set takes the tightest device's fit. The
        # queues are charged before the dense budget: they scale with the chunk
        # size and, at the auto chunk size, are the same order as the dense maps.
        # ``n_beamlets * per_batch`` bounds the block a group can ever launch, so
        # this over- rather than under-states the queue cost.
        def _fit(d: str) -> int:
            lanes_d = min(concurrent_batches, n_batches) if wp.get_device(d).is_cuda else 1
            chunk_d = min(self._chunk_histories(d), n_beamlets * per_batch)
            return _auto_group_size(
                _device_free_bytes(d),
                scoring.n_voxels,
                n_beamlets,
                lanes_d,
                _queue_bytes(chunk_d, self.queue_factor, lanes_d),
            )

        beamlet_group_size = min(_fit(d) for d in device_list)

    # Beamlet groups are independent — no cross-group reduction — so sharding them
    # over devices needs no communication at all. Assignment is greedy from a
    # shared ordered queue: a device pulls the next group the moment it is free,
    # so an idle device always takes new work before any deeper concurrency
    # (batch lanes) on a busy one — the scheduling preference this engine
    # promises. Each column is computed wholly on one device, so scheduling can
    # never change a column's value on a given device, only which device
    # produces it (test-pinned). Over *identical* devices the whole Dij is
    # therefore bit-identical however the pulls interleave; over a mixed set
    # (e.g. cuda + cpu) the column-to-device assignment is timing-dependent run
    # to run, each column still bit-equal to a whole run of its computing device
    # — pass a single device where strict run-to-run reproducibility on mixed
    # hardware matters. The energy books are exact integer quanta either way.
    group_starts = list(range(0, n_beamlets, beamlet_group_size))
    pending = deque(group_starts)
    pending_lock = threading.Lock()

    def _next_group() -> int | None:
        with pending_lock:
            return pending.popleft() if pending else None

    assembler = DijAssembler(
        grid_shape=scoring.shape,
        n_beamlets=n_beamlets,
        n_histories_per_beamlet=n_histories_per_beamlet,
        n_batches=n_batches,
        truncation=truncation,
        correlated=correlated,
        scoring_mode=scoring_mode,
        provenance=self._provenance(
            seed,
            pcut,
            ecut,
            msc_model,
            step_energy_fraction,
            deposit_resolution_cm,
            devices=device_list,
        ),
    )

    emitter = ProgressEmitter(progress, n_beamlets * n_histories_per_beamlet)
    args = dict(
        source=source,
        n_histories_per_beamlet=n_histories_per_beamlet,
        n_batches=n_batches,
        per_batch=per_batch,
        seed=seed,
        pcut=pcut,
        ecut=ecut,
        step_energy_fraction=step_energy_fraction,
        deposit_resolution=deposit_resolution,
        msc_model_gs=msc_model_gs,
        transport_electrons=transport_electrons,
        truncation=truncation,
        correlated=correlated,
        beamlet_group_size=beamlet_group_size,
        scoring=scoring,
        dose_to_water=dose_to_water,
        concurrent_batches=concurrent_batches,
        emitter=emitter,
    )
    # Kernel modules are loaded up front whenever any host thread beyond this
    # one will launch (device workers or batch lanes): warp's module loading is
    # not thread-safe. Everything after takes its device explicitly, so the
    # threads share no warp state.
    if len(device_list) > 1 or concurrent_batches > 1:
        for d in device_list:
            wp.load_module(kernels, device=d)
    if len(device_list) == 1:
        shard_results = [self._run_dij_shard(device_list[0], _next_group, **args)]
    else:
        # One host thread per device: warp launches are asynchronous but the drain
        # loop's count readbacks block, so a single thread would serialize the
        # devices on those readbacks.
        with ThreadPoolExecutor(max_workers=len(device_list)) as pool:
            futures = [
                pool.submit(self._run_dij_shard, d, _next_group, **args) for d in device_list
            ]
            shard_results = [f.result() for f in futures]

    # Energy books in exact integer quanta (Python ints, unbounded), converted
    # to MeV once at the end: float accumulation order would otherwise make
    # the tallies — unlike the matrix — depend on the group size. Summed over
    # shards in device-list order, so the books do not depend on which device
    # finished first.
    deposited_quanta = sum(r.deposited_quanta for r in shard_results)
    escaped_quanta = sum(r.escaped_quanta for r in shard_results)
    unscored_quanta = sum(r.unscored_quanta for r in shard_results)
    emitted_quanta = sum(r.emitted_quanta for r in shard_results)
    emitted_energy = sum(r.emitted_energy for r in shard_results)

    # The assembler takes blocks in ascending, gap-free beamlet order; shards
    # finish in whatever order the devices happen to, so feed it from the merged
    # map rather than as the blocks arrive.
    blocks = {gs: block for r in shard_results for gs, block in r.blocks.items()}
    for group_start in group_starts:
        counts, indices, dose, sigma = blocks[group_start]
        assembler.add_sparse_block(group_start, counts, indices, dose, sigma)

    use_lattice = isinstance(source, BeamletGridSource)
    if use_lattice:
        emitted_energy = n_beamlets * n_histories_per_beamlet * source.max_energy
    elif emitted_quanta:
        emitted_energy = float(emitted_quanta) * ENERGY_QUANTUM_MEV
    return assembler.finalize(
        energy_emitted=emitted_energy,
        energy_deposited=deposited_quanta * ENERGY_QUANTUM_MEV,
        energy_escaped=escaped_quanta * ENERGY_QUANTUM_MEV,
        energy_unscored=unscored_quanta * ENERGY_QUANTUM_MEV,
    )

Results

pyradmc.backends.results.TransportResult dataclass

One engine run: batched dose estimate plus exact energy bookkeeping.

energy_emitted == energy_deposited + energy_unscored + energy_escaped holds to accumulation precision of the producing backend — float64 exact for ref, float32 transport arithmetic plus scoring quantization for warp — and is asserted in the integration tier at each backend's documented tolerance.

energy_escaped is a ledger, not purely physical escape: it also carries the net weight-energy Russian roulette removes from the transported population (kills positive, survivor boosts negative), which is exactly what keeps the identity above exact per run under variance reduction.

energy_unscored is deposit energy that landed inside the transport grid but outside the scoring grid (decoupled dose grid). It is exactly zero when the scoring grid covers the transport grid — in particular for the default score-on-the-transport-grid configuration.

Source code in pyradmc/backends/results.py
@dataclass(frozen=True)
class TransportResult:
    """One engine run: batched dose estimate plus exact energy bookkeeping.

    ``energy_emitted == energy_deposited + energy_unscored + energy_escaped`` holds
    to accumulation precision of the producing backend — float64 exact for ``ref``,
    float32 transport arithmetic plus scoring quantization for ``warp`` — and is
    asserted in the integration tier at each backend's documented tolerance.

    ``energy_escaped`` is a *ledger*, not purely physical escape: it
    also carries the net weight-energy Russian roulette removes from the transported
    population (kills positive, survivor boosts negative), which is exactly what
    keeps the identity above exact per run under variance reduction.

    ``energy_unscored`` is deposit energy that landed inside the transport grid but
    outside the scoring grid (decoupled dose grid). It is exactly zero when
    the scoring grid covers the transport grid — in particular for the default
    score-on-the-transport-grid configuration.
    """

    dose: np.ndarray
    """Per-voxel dose on the scoring grid, MeV/g per emitted history."""
    dose_sigma: np.ndarray
    """Per-voxel 1-sigma standard error from batch statistics."""
    energy_emitted: float
    energy_deposited: float
    energy_escaped: float
    n_histories: int
    n_batches: int
    energy_unscored: float = 0.0
    scoring_mode: str = "dose_to_medium"
    """Tally weighting the dose was produced under: ``"dose_to_medium"``
    or ``"dose_to_water"``. The energy books are physical in both modes."""
    provenance: RunProvenance | None = None
    """How this result was produced; see :class:`RunProvenance`.

    Optional on the dataclass so that a hand-assembled result (a test instrument, a
    reload from disk) need not fabricate one, but **every engine run populates it** —
    that contract is test-pinned rather than expressed in the type, because it is a
    property of the engines, not of the container."""

dose instance-attribute

dose: ndarray

Per-voxel dose on the scoring grid, MeV/g per emitted history.

dose_sigma instance-attribute

dose_sigma: ndarray

Per-voxel 1-sigma standard error from batch statistics.

scoring_mode class-attribute instance-attribute

scoring_mode: str = 'dose_to_medium'

Tally weighting the dose was produced under: "dose_to_medium" or "dose_to_water". The energy books are physical in both modes.

provenance class-attribute instance-attribute

provenance: RunProvenance | None = None

How this result was produced; see :class:RunProvenance.

Optional on the dataclass so that a hand-assembled result (a test instrument, a reload from disk) need not fabricate one, but every engine run populates it — that contract is test-pinned rather than expressed in the type, because it is a property of the engines, not of the container.

pyradmc.backends.results.RunProvenance dataclass

The configuration a result was produced under, carried with the result.

A dose array outlives the process that made it: it is archived, handed to an optimizer, attached to a plan, compared against a run from six months ago. Every field here changes the numbers, and none of them is recoverable from the array afterwards — so a result that does not carry them is not reproducible, however carefully the run was scripted.

This is a record, not a control surface: constructing one does not configure anything, and the engines fill it in from the arguments they were actually called with (a resolved step_energy_fraction, not the None the caller may have passed).

Source code in pyradmc/backends/results.py
@dataclass(frozen=True)
class RunProvenance:
    """The configuration a result was produced under, carried with the result.

    A dose array outlives the process that made it: it is archived, handed to an
    optimizer, attached to a plan, compared against a run from six months ago. Every
    field here changes the numbers, and none of them is recoverable from the array
    afterwards — so a result that does not carry them is not reproducible, however
    carefully the run was scripted.

    This is a record, not a control surface: constructing one does not configure
    anything, and the engines fill it in from the arguments they were actually
    called with (a resolved ``step_energy_fraction``, not the ``None`` the caller
    may have passed).
    """

    version: str
    """``pyradmc.__version__`` of the engine that produced the result."""
    backend: str
    """``"ref"`` or ``"warp"``."""
    device: str
    """``"cpu"`` or a CUDA device such as ``"cuda:0"``. Results are bit-reproducible
    for a given seed *on one device*, never across devices (AGENTS.md section 2.3)."""
    seed: int
    """Global seed; history ``i`` used the stream ``(seed, i)``."""
    pcut_mev: float
    """Photon transport cutoff in MeV."""
    ecut_mev: float
    """Electron transport and production cutoff, kinetic energy in MeV."""
    msc_model: str
    """Multiple-scattering model: ``"gs"`` (shipped) or ``"gaussian"``."""
    step_energy_fraction: float
    """Resolved electron substep energy-loss fraction, never ``None``."""
    cross_sections: str
    """Cross-section provenance, from
    :attr:`~pyradmc.data.interface.CrossSectionSource.provenance` — the compiled
    library citation for a tabulated source, the parameterization for the analytic
    one. This is the field that distinguishes two otherwise identical runs."""
    deposit_resolution_cm: float | None = None
    """Longest piece a half-substep's continuous energy loss was filed as, in cm.

    ``None`` is the single midpoint deposit — the default, and what every result
    produced before this option existed used. A value moves dose *within* a
    transport voxel (never between voxels, and never any total), so two runs that
    differ only here agree on the energy books and on any dose scored at voxel
    resolution, and can differ below it. That is exactly why it is recorded: it is
    not recoverable from the dose array."""

    def summary(self) -> str:
        """One-line human-readable digest, for logs and file headers."""
        return (
            f"pyradmc {self.version} {self.backend}/{self.device} seed={self.seed} "
            f"pcut={self.pcut_mev} ecut={self.ecut_mev} msc={self.msc_model} "
            f"step={self.step_energy_fraction} deposit_res={self.deposit_resolution_cm} "
            f"xs=[{self.cross_sections}]"
        )

version instance-attribute

version: str

pyradmc.__version__ of the engine that produced the result.

backend instance-attribute

backend: str

"ref" or "warp".

device instance-attribute

device: str

"cpu" or a CUDA device such as "cuda:0". Results are bit-reproducible for a given seed on one device, never across devices (AGENTS.md section 2.3).

seed instance-attribute

seed: int

Global seed; history i used the stream (seed, i).

pcut_mev instance-attribute

pcut_mev: float

Photon transport cutoff in MeV.

ecut_mev instance-attribute

ecut_mev: float

Electron transport and production cutoff, kinetic energy in MeV.

msc_model instance-attribute

msc_model: str

Multiple-scattering model: "gs" (shipped) or "gaussian".

step_energy_fraction instance-attribute

step_energy_fraction: float

Resolved electron substep energy-loss fraction, never None.

cross_sections instance-attribute

cross_sections: str

Cross-section provenance, from :attr:~pyradmc.data.interface.CrossSectionSource.provenance — the compiled library citation for a tabulated source, the parameterization for the analytic one. This is the field that distinguishes two otherwise identical runs.

deposit_resolution_cm class-attribute instance-attribute

deposit_resolution_cm: float | None = None

Longest piece a half-substep's continuous energy loss was filed as, in cm.

None is the single midpoint deposit — the default, and what every result produced before this option existed used. A value moves dose within a transport voxel (never between voxels, and never any total), so two runs that differ only here agree on the energy books and on any dose scored at voxel resolution, and can differ below it. That is exactly why it is recorded: it is not recoverable from the dose array.

summary

summary() -> str

One-line human-readable digest, for logs and file headers.

Source code in pyradmc/backends/results.py
def summary(self) -> str:
    """One-line human-readable digest, for logs and file headers."""
    return (
        f"pyradmc {self.version} {self.backend}/{self.device} seed={self.seed} "
        f"pcut={self.pcut_mev} ecut={self.ecut_mev} msc={self.msc_model} "
        f"step={self.step_energy_fraction} deposit_res={self.deposit_resolution_cm} "
        f"xs=[{self.cross_sections}]"
    )

pyradmc.scoring.dij.DijResult dataclass

Sparse beamlet-resolved dose influence matrix, CSC layout by column.

Column j holds beamlet j's dose per emitted history (MeV/g) in the voxels that survived truncation; sigma is the matching per-entry standard error. Voxel indices are flat C-order over grid_shape.

correlated records the stream mapping the Dij was computed under . When True, columns share random streams and are statistically dependent: each per-entry sigma stays valid on its own, but sigmas must never be combined across columns in quadrature — cross-column covariance is not carried here.

Source code in pyradmc/scoring/dij.py
@dataclass(frozen=True)
class DijResult:
    """Sparse beamlet-resolved dose influence matrix, CSC layout by column.

    Column ``j`` holds beamlet ``j``'s dose per emitted history (MeV/g) in the
    voxels that survived truncation; ``sigma`` is the matching per-entry standard
    error. Voxel indices are flat C-order over ``grid_shape``.

    ``correlated`` records the stream mapping the Dij was computed under
    . When True, columns share random streams and are statistically
    *dependent*: each per-entry ``sigma`` stays valid on its own, but sigmas
    must never be combined across columns in quadrature — cross-column
    covariance is not carried here.
    """

    grid_shape: tuple[int, int, int]
    n_beamlets: int
    n_histories_per_beamlet: int
    n_batches: int
    truncation: float
    indptr: np.ndarray
    indices: np.ndarray
    dose: np.ndarray
    sigma: np.ndarray
    energy_emitted: float
    energy_deposited: float
    energy_escaped: float
    energy_unscored: float = 0.0
    correlated: bool = False
    scoring_mode: str = "dose_to_medium"
    provenance: RunProvenance | None = None
    """How this matrix was produced; see
    :class:`~pyradmc.backends.results.RunProvenance`. Every engine ``run_dij``
    populates it (test-pinned); optional on the dataclass so a hand-assembled or
    reloaded matrix need not fabricate one."""

    @property
    def n_voxels(self) -> int:
        """Number of voxels (rows of the matrix)."""
        return int(np.prod(self.grid_shape))

    def column_dense(self, beamlet: int) -> np.ndarray:
        """One beamlet's truncated dose column as a dense ``grid_shape`` array."""
        return self._scatter(self.dose, beamlet)

    def sigma_dense(self, beamlet: int) -> np.ndarray:
        """One beamlet's per-entry sigma as a dense ``grid_shape`` array."""
        return self._scatter(self.sigma, beamlet)

    def _scatter(self, values: np.ndarray, beamlet: int) -> np.ndarray:
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside 0..{self.n_beamlets - 1}")
        lo, hi = int(self.indptr[beamlet]), int(self.indptr[beamlet + 1])
        out = np.zeros(self.n_voxels, dtype=np.float64)
        out[self.indices[lo:hi]] = values[lo:hi]
        return out.reshape(self.grid_shape)

    def dose_for_weights(self, weights: np.ndarray) -> np.ndarray:
        """Dense dose grid for a fluence-weight vector: ``sum_j w_j * column_j``."""
        w = np.asarray(weights, dtype=np.float64)
        if w.shape != (self.n_beamlets,):
            raise ValueError(f"weights shape {w.shape} != ({self.n_beamlets},)")
        out = np.zeros(self.n_voxels, dtype=np.float64)
        for j in range(self.n_beamlets):
            lo, hi = int(self.indptr[j]), int(self.indptr[j + 1])
            np.add.at(out, self.indices[lo:hi], w[j] * self.dose[lo:hi])
        return out.reshape(self.grid_shape)

    def dose_csc(self, unit: str = "mev_per_g") -> csc_array:
        """Export the dose matrix as a ``scipy.sparse.csc_array``, (n_voxels, n_beamlets).

        ``unit="mev_per_g"`` (default) is the engines' native score, per emitted
        history of each column's beamlet; ``unit="gy"`` applies the exact SI
        calibration :data:`pyradmc.GY_PER_MEV_PER_G` for absolute dose per
        history — a planning consumer scales by its own particles-per-MU on top.
        """
        return self._csc(self.dose, self._unit_factor(unit))

    def sigma_csc(self, unit: str = "mev_per_g") -> csc_array:
        """Export the per-entry sigma as a ``scipy.sparse.csc_array``, aligned with the dose.

        Valid per column (per-beamlet QA) in either mode; see :meth:`variance_csc`
        for the cross-column caveat under correlated sampling.
        """
        return self._csc(self.sigma, self._unit_factor(unit))

    def variance_csc(self, unit: str = "mev_per_g") -> csc_array:
        """Export the per-entry variance (``sigma**2``), aligned with the dose.

        The export a planning consumer stores as its dose-influence variance
        matrix (pyRadPlan's ``physical_dose_var``). Each entry is valid on its
        own, but when the Dij was computed under **correlated sampling** (the
        shipped default) the columns are statistically *dependent*, so any
        cross-column combination of these variances — ``variance @ weights``, a
        quadrature plan-dose sigma — is invalid (AGENTS.md section 8).
        That known downstream use is why this export carries a
        :func:`warnings.warn` result caveat when ``correlated`` is True; compute
        a plan-dose sigma from batch-resolved data or an independent-columns run
        instead.
        """
        if self.correlated:
            warnings.warn(
                "this Dij was sampled with correlated columns: per-entry variances "
                "are valid within a column, but combining them across columns "
                "(e.g. a quadrature plan-dose variance) is invalid because the "
                "columns are statistically dependent",
                UserWarning,
                stacklevel=2,
            )
        return self._csc(self.sigma**2, self._unit_factor(unit) ** 2)

    @staticmethod
    def _unit_factor(unit: str) -> float:
        """Return the linear scale for a unit choice; variance exports square it."""
        if unit == "mev_per_g":
            return 1.0
        if unit == "gy":
            return GY_PER_MEV_PER_G
        raise ValueError(f"unknown unit {unit!r}; expected 'mev_per_g' or 'gy'")

    def _csc(self, values: np.ndarray, factor: float) -> csc_array:
        from scipy.sparse import csc_array

        data = values if factor == 1.0 else values * factor
        return csc_array((data, self.indices, self.indptr), shape=(self.n_voxels, self.n_beamlets))

provenance class-attribute instance-attribute

provenance: RunProvenance | None = None

How this matrix was produced; see :class:~pyradmc.backends.results.RunProvenance. Every engine run_dij populates it (test-pinned); optional on the dataclass so a hand-assembled or reloaded matrix need not fabricate one.

n_voxels property

n_voxels: int

Number of voxels (rows of the matrix).

column_dense

column_dense(beamlet: int) -> np.ndarray

One beamlet's truncated dose column as a dense grid_shape array.

Source code in pyradmc/scoring/dij.py
def column_dense(self, beamlet: int) -> np.ndarray:
    """One beamlet's truncated dose column as a dense ``grid_shape`` array."""
    return self._scatter(self.dose, beamlet)

sigma_dense

sigma_dense(beamlet: int) -> np.ndarray

One beamlet's per-entry sigma as a dense grid_shape array.

Source code in pyradmc/scoring/dij.py
def sigma_dense(self, beamlet: int) -> np.ndarray:
    """One beamlet's per-entry sigma as a dense ``grid_shape`` array."""
    return self._scatter(self.sigma, beamlet)

dose_for_weights

dose_for_weights(weights: ndarray) -> np.ndarray

Dense dose grid for a fluence-weight vector: sum_j w_j * column_j.

Source code in pyradmc/scoring/dij.py
def dose_for_weights(self, weights: np.ndarray) -> np.ndarray:
    """Dense dose grid for a fluence-weight vector: ``sum_j w_j * column_j``."""
    w = np.asarray(weights, dtype=np.float64)
    if w.shape != (self.n_beamlets,):
        raise ValueError(f"weights shape {w.shape} != ({self.n_beamlets},)")
    out = np.zeros(self.n_voxels, dtype=np.float64)
    for j in range(self.n_beamlets):
        lo, hi = int(self.indptr[j]), int(self.indptr[j + 1])
        np.add.at(out, self.indices[lo:hi], w[j] * self.dose[lo:hi])
    return out.reshape(self.grid_shape)

dose_csc

dose_csc(unit: str = 'mev_per_g') -> csc_array

Export the dose matrix as a scipy.sparse.csc_array, (n_voxels, n_beamlets).

unit="mev_per_g" (default) is the engines' native score, per emitted history of each column's beamlet; unit="gy" applies the exact SI calibration :data:pyradmc.GY_PER_MEV_PER_G for absolute dose per history — a planning consumer scales by its own particles-per-MU on top.

Source code in pyradmc/scoring/dij.py
def dose_csc(self, unit: str = "mev_per_g") -> csc_array:
    """Export the dose matrix as a ``scipy.sparse.csc_array``, (n_voxels, n_beamlets).

    ``unit="mev_per_g"`` (default) is the engines' native score, per emitted
    history of each column's beamlet; ``unit="gy"`` applies the exact SI
    calibration :data:`pyradmc.GY_PER_MEV_PER_G` for absolute dose per
    history — a planning consumer scales by its own particles-per-MU on top.
    """
    return self._csc(self.dose, self._unit_factor(unit))

sigma_csc

sigma_csc(unit: str = 'mev_per_g') -> csc_array

Export the per-entry sigma as a scipy.sparse.csc_array, aligned with the dose.

Valid per column (per-beamlet QA) in either mode; see :meth:variance_csc for the cross-column caveat under correlated sampling.

Source code in pyradmc/scoring/dij.py
def sigma_csc(self, unit: str = "mev_per_g") -> csc_array:
    """Export the per-entry sigma as a ``scipy.sparse.csc_array``, aligned with the dose.

    Valid per column (per-beamlet QA) in either mode; see :meth:`variance_csc`
    for the cross-column caveat under correlated sampling.
    """
    return self._csc(self.sigma, self._unit_factor(unit))

variance_csc

variance_csc(unit: str = 'mev_per_g') -> csc_array

Export the per-entry variance (sigma**2), aligned with the dose.

The export a planning consumer stores as its dose-influence variance matrix (pyRadPlan's physical_dose_var). Each entry is valid on its own, but when the Dij was computed under correlated sampling (the shipped default) the columns are statistically dependent, so any cross-column combination of these variances — variance @ weights, a quadrature plan-dose sigma — is invalid (AGENTS.md section 8). That known downstream use is why this export carries a :func:warnings.warn result caveat when correlated is True; compute a plan-dose sigma from batch-resolved data or an independent-columns run instead.

Source code in pyradmc/scoring/dij.py
def variance_csc(self, unit: str = "mev_per_g") -> csc_array:
    """Export the per-entry variance (``sigma**2``), aligned with the dose.

    The export a planning consumer stores as its dose-influence variance
    matrix (pyRadPlan's ``physical_dose_var``). Each entry is valid on its
    own, but when the Dij was computed under **correlated sampling** (the
    shipped default) the columns are statistically *dependent*, so any
    cross-column combination of these variances — ``variance @ weights``, a
    quadrature plan-dose sigma — is invalid (AGENTS.md section 8).
    That known downstream use is why this export carries a
    :func:`warnings.warn` result caveat when ``correlated`` is True; compute
    a plan-dose sigma from batch-resolved data or an independent-columns run
    instead.
    """
    if self.correlated:
        warnings.warn(
            "this Dij was sampled with correlated columns: per-entry variances "
            "are valid within a column, but combining them across columns "
            "(e.g. a quadrature plan-dose variance) is invalid because the "
            "columns are statistically dependent",
            UserWarning,
            stacklevel=2,
        )
    return self._csc(self.sigma**2, self._unit_factor(unit) ** 2)

Geometry and scoring

pyradmc.geometry.grid.VoxelGrid dataclass

Axis-aligned voxel grid with per-voxel density and material.

Voxels are half-open boxes: a position on an internal boundary belongs to the voxel on the upper side, and positions on the upper outer faces are outside. This convention is test-pinned; changing it moves dose by one voxel at every boundary.

Attributes:

Name Type Description
shape tuple[int, int, int]

Number of voxels along (x, y, z).

spacing tuple[float, float, float]

Voxel edge lengths in cm.

origin tuple[float, float, float]

Position of the lower corner of voxel (0, 0, 0), in cm.

density ndarray

Mass density per voxel in g/cm^3, shape shape.

material ndarray

Material index per voxel (see :mod:pyradmc.data.materials), shape shape.

Source code in pyradmc/geometry/grid.py
@dataclass(frozen=True)
class VoxelGrid:
    """Axis-aligned voxel grid with per-voxel density and material.

    Voxels are half-open boxes: a position on an internal boundary belongs to the
    voxel on the upper side, and positions on the upper outer faces are outside. This
    convention is test-pinned; changing it moves dose by one voxel at every boundary.

    Attributes
    ----------
    shape
        Number of voxels along (x, y, z).
    spacing
        Voxel edge lengths in cm.
    origin
        Position of the lower corner of voxel (0, 0, 0), in cm.
    density
        Mass density per voxel in g/cm^3, shape ``shape``.
    material
        Material index per voxel (see :mod:`pyradmc.data.materials`), shape ``shape``.
    """

    shape: tuple[int, int, int]
    spacing: tuple[float, float, float]
    density: np.ndarray
    material: np.ndarray
    origin: tuple[float, float, float] = (0.0, 0.0, 0.0)

    def __post_init__(self) -> None:
        """Validate array shapes, dtypes, and physical ranges."""
        if any(n < 1 for n in self.shape):
            raise ValueError(f"empty grid shape {self.shape}")
        if any(s <= 0.0 for s in self.spacing):
            raise ValueError(f"non-positive spacing {self.spacing}")
        if self.density.shape != self.shape:
            raise ValueError(f"density shape {self.density.shape} != grid shape {self.shape}")
        if self.material.shape != self.shape:
            raise ValueError(f"material shape {self.material.shape} != grid shape {self.shape}")
        if np.any(self.density <= 0.0):
            raise ValueError("non-positive voxel density; vacuum is not supported")
        if np.any((self.material < 0) | (self.material >= len(MATERIALS))):
            raise ValueError("material index outside the registry")

    @classmethod
    def uniform_water(
        cls,
        shape: tuple[int, int, int],
        spacing: tuple[float, float, float],
        origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
    ) -> VoxelGrid:
        """Build a homogeneous unit-density water grid, the workhorse phantom."""
        return cls(
            shape=shape,
            spacing=spacing,
            origin=origin,
            density=np.ones(shape, dtype=np.float64),
            material=np.full(shape, WATER, dtype=np.int32),
        )

    @property
    def voxel_volume(self) -> float:
        """Volume of one voxel in cm^3."""
        return self.spacing[0] * self.spacing[1] * self.spacing[2]

    @property
    def upper_corner(self) -> tuple[float, float, float]:
        """Position of the upper outer corner, in cm (outside, half-open)."""
        return (
            self.origin[0] + self.shape[0] * self.spacing[0],
            self.origin[1] + self.shape[1] * self.spacing[1],
            self.origin[2] + self.shape[2] * self.spacing[2],
        )

    def contains(self, x: float, y: float, z: float) -> bool:
        """Whether the position lies inside the grid (upper faces excluded)."""
        hi = self.upper_corner
        return point_inside(x, y, z, *self.origin, *hi)

    def voxel_index(self, x: float, y: float, z: float) -> tuple[int, int, int]:
        """Voxel containing the position; the caller guarantees ``contains``."""
        return (
            point_axis_index(x, self.origin[0], self.spacing[0], self.shape[0]),
            point_axis_index(y, self.origin[1], self.spacing[1], self.shape[1]),
            point_axis_index(z, self.origin[2], self.spacing[2], self.shape[2]),
        )

    def distance_to_entry(
        self, x: float, y: float, z: float, ux: float, uy: float, uz: float
    ) -> float:
        """Distance along (ux, uy, uz) to the grid surface; 0 inside; inf if missed.

        The region outside the grid is vacuum, so a particle born outside flies this
        distance for free before Woodcock tracking starts. The grid is convex: a
        straight flight that leaves it never re-enters, so this is only ever needed
        once per particle. Clipping delegates to :func:`slab_entry_distance`.
        """
        if self.contains(x, y, z):
            return 0.0
        return slab_entry_distance(x, y, z, ux, uy, uz, *self.origin, *self.upper_corner)

    def max_density_by_material(self) -> tuple[tuple[int, float], ...]:
        """Collect the ``(material, max density)`` pairs, for the Woodcock majorant.

        Feeding anything less than the true per-material maximum into
        :meth:`pyradmc.data.interface.CrossSectionSource.majorant` silently biases
        the transport; this method exists so callers never compute it by hand.
        """
        pairs = []
        for material in np.unique(self.material):
            rho_max = float(self.density[self.material == material].max())
            pairs.append((int(material), rho_max))
        return tuple(pairs)

voxel_volume property

voxel_volume: float

Volume of one voxel in cm^3.

upper_corner property

upper_corner: tuple[float, float, float]

Position of the upper outer corner, in cm (outside, half-open).

uniform_water classmethod

uniform_water(shape: tuple[int, int, int], spacing: tuple[float, float, float], origin: tuple[float, float, float] = (0.0, 0.0, 0.0)) -> VoxelGrid

Build a homogeneous unit-density water grid, the workhorse phantom.

Source code in pyradmc/geometry/grid.py
@classmethod
def uniform_water(
    cls,
    shape: tuple[int, int, int],
    spacing: tuple[float, float, float],
    origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> VoxelGrid:
    """Build a homogeneous unit-density water grid, the workhorse phantom."""
    return cls(
        shape=shape,
        spacing=spacing,
        origin=origin,
        density=np.ones(shape, dtype=np.float64),
        material=np.full(shape, WATER, dtype=np.int32),
    )

contains

contains(x: float, y: float, z: float) -> bool

Whether the position lies inside the grid (upper faces excluded).

Source code in pyradmc/geometry/grid.py
def contains(self, x: float, y: float, z: float) -> bool:
    """Whether the position lies inside the grid (upper faces excluded)."""
    hi = self.upper_corner
    return point_inside(x, y, z, *self.origin, *hi)

voxel_index

voxel_index(x: float, y: float, z: float) -> tuple[int, int, int]

Voxel containing the position; the caller guarantees contains.

Source code in pyradmc/geometry/grid.py
def voxel_index(self, x: float, y: float, z: float) -> tuple[int, int, int]:
    """Voxel containing the position; the caller guarantees ``contains``."""
    return (
        point_axis_index(x, self.origin[0], self.spacing[0], self.shape[0]),
        point_axis_index(y, self.origin[1], self.spacing[1], self.shape[1]),
        point_axis_index(z, self.origin[2], self.spacing[2], self.shape[2]),
    )

distance_to_entry

distance_to_entry(x: float, y: float, z: float, ux: float, uy: float, uz: float) -> float

Distance along (ux, uy, uz) to the grid surface; 0 inside; inf if missed.

The region outside the grid is vacuum, so a particle born outside flies this distance for free before Woodcock tracking starts. The grid is convex: a straight flight that leaves it never re-enters, so this is only ever needed once per particle. Clipping delegates to :func:slab_entry_distance.

Source code in pyradmc/geometry/grid.py
def distance_to_entry(
    self, x: float, y: float, z: float, ux: float, uy: float, uz: float
) -> float:
    """Distance along (ux, uy, uz) to the grid surface; 0 inside; inf if missed.

    The region outside the grid is vacuum, so a particle born outside flies this
    distance for free before Woodcock tracking starts. The grid is convex: a
    straight flight that leaves it never re-enters, so this is only ever needed
    once per particle. Clipping delegates to :func:`slab_entry_distance`.
    """
    if self.contains(x, y, z):
        return 0.0
    return slab_entry_distance(x, y, z, ux, uy, uz, *self.origin, *self.upper_corner)

max_density_by_material

max_density_by_material() -> tuple[tuple[int, float], ...]

Collect the (material, max density) pairs, for the Woodcock majorant.

Feeding anything less than the true per-material maximum into :meth:pyradmc.data.interface.CrossSectionSource.majorant silently biases the transport; this method exists so callers never compute it by hand.

Source code in pyradmc/geometry/grid.py
def max_density_by_material(self) -> tuple[tuple[int, float], ...]:
    """Collect the ``(material, max density)`` pairs, for the Woodcock majorant.

    Feeding anything less than the true per-material maximum into
    :meth:`pyradmc.data.interface.CrossSectionSource.majorant` silently biases
    the transport; this method exists so callers never compute it by hand.
    """
    pairs = []
    for material in np.unique(self.material):
        rho_max = float(self.density[self.material == material].max())
        pairs.append((int(material), rho_max))
    return tuple(pairs)

pyradmc.scoring.grid.ScoringGrid dataclass

Axis-aligned dose-scoring grid with per-voxel mass from the transport grid.

Construct through :meth:for_grid (score on the transport grid itself — the engines' default, byte-identical to scoring without a separate dose grid) or :meth:rebin (arbitrary geometry, mass by exact voxel overlap). The mass map is only meaningful for the transport grid it was built from; hand the engine a scoring grid built from the same :class:~pyradmc.geometry.grid.VoxelGrid it transports on.

Voxels follow the transport grid's half-open convention (lower faces inside, upper faces outside), delegated to the shared geometry primitives.

Attributes:

Name Type Description
shape tuple[int, int, int]

Number of scoring voxels along (x, y, z).

spacing tuple[float, float, float]

Scoring voxel edge lengths in cm.

origin tuple[float, float, float]

Position of the lower corner of scoring voxel (0, 0, 0), in cm.

voxel_mass ndarray

Mass per scoring voxel in g, shape shape. Zero where the transport grid does not cover the scoring voxel (dose is reported as zero there — no energy can arrive without transport-grid coverage).

Source code in pyradmc/scoring/grid.py
@dataclass(frozen=True)
class ScoringGrid:
    """Axis-aligned dose-scoring grid with per-voxel mass from the transport grid.

    Construct through :meth:`for_grid` (score on the transport grid itself — the
    engines' default, byte-identical to scoring without a separate dose grid) or
    :meth:`rebin` (arbitrary geometry, mass by exact voxel overlap). The mass map
    is only meaningful for the transport grid it was built from; hand the engine
    a scoring grid built from the same :class:`~pyradmc.geometry.grid.VoxelGrid`
    it transports on.

    Voxels follow the transport grid's half-open convention (lower faces inside,
    upper faces outside), delegated to the shared geometry primitives.

    Attributes
    ----------
    shape
        Number of scoring voxels along (x, y, z).
    spacing
        Scoring voxel edge lengths in cm.
    origin
        Position of the lower corner of scoring voxel (0, 0, 0), in cm.
    voxel_mass
        Mass per scoring voxel in g, shape ``shape``. Zero where the transport
        grid does not cover the scoring voxel (dose is reported as zero there —
        no energy can arrive without transport-grid coverage).
    """

    shape: tuple[int, int, int]
    spacing: tuple[float, float, float]
    origin: tuple[float, float, float]
    voxel_mass: np.ndarray

    def __post_init__(self) -> None:
        """Validate geometry and mass-map consistency."""
        if any(n < 1 for n in self.shape):
            raise ValueError(f"empty scoring grid shape {self.shape}")
        if any(s <= 0.0 for s in self.spacing):
            raise ValueError(f"non-positive scoring spacing {self.spacing}")
        if self.voxel_mass.shape != self.shape:
            raise ValueError(
                f"voxel_mass shape {self.voxel_mass.shape} != scoring grid shape {self.shape}"
            )
        if np.any(self.voxel_mass < 0.0):
            raise ValueError("negative scoring voxel mass")

    @classmethod
    def for_grid(cls, grid: VoxelGrid) -> ScoringGrid:
        """Score on the transport grid itself: same geometry, mass = density * volume.

        This is the engines' ``scoring_grid=None`` default. The mass is the direct
        per-voxel product — the identical arithmetic the scorer used before scoring
        grids existed — not an overlap rebin, so the default path stays
        byte-identical, not merely equal to rounding.
        """
        return cls(
            shape=grid.shape,
            spacing=grid.spacing,
            origin=grid.origin,
            voxel_mass=grid.density * grid.voxel_volume,
        )

    @classmethod
    def rebin(
        cls,
        grid: VoxelGrid,
        shape: tuple[int, int, int],
        spacing: tuple[float, float, float],
        origin: tuple[float, float, float],
    ) -> ScoringGrid:
        """Build a scoring grid of arbitrary geometry, mass by exact voxel overlap.

        ``mass_J = sum_i rho_i * prod_axis overlap_1d`` over transport voxels ``i``:
        exact for non-aligned and non-integer-ratio grids, and it degrades gracefully
        at the edges — a partially covered scoring voxel carries the mass of the
        covered part only, which is also the only part deposits can arrive in.
        """
        if any(n < 1 for n in shape):
            raise ValueError(f"empty scoring grid shape {shape}")
        if any(s <= 0.0 for s in spacing):
            raise ValueError(f"non-positive scoring spacing {spacing}")
        wx = _axis_overlap_lengths(
            origin[0], spacing[0], shape[0], grid.origin[0], grid.spacing[0], grid.shape[0]
        )
        wy = _axis_overlap_lengths(
            origin[1], spacing[1], shape[1], grid.origin[1], grid.spacing[1], grid.shape[1]
        )
        wz = _axis_overlap_lengths(
            origin[2], spacing[2], shape[2], grid.origin[2], grid.spacing[2], grid.shape[2]
        )
        # Separable contraction: mass_abc = sum_ijk rho_ijk * wx_ai * wy_bj * wz_ck.
        partial_x = np.tensordot(wx, grid.density, axes=(1, 0))  # (a, j, k)
        partial_xy = np.einsum("bj,ajk->abk", wy, partial_x)  # (a, b, k)
        mass = np.einsum("ck,abk->abc", wz, partial_xy)  # (a, b, c)
        return cls(shape=shape, spacing=spacing, origin=origin, voxel_mass=mass)

    @property
    def n_voxels(self) -> int:
        """Number of scoring voxels."""
        return self.shape[0] * self.shape[1] * self.shape[2]

    @property
    def deposit_resolution_cm(self) -> float:
        """Suggested ``deposit_resolution_cm`` for an engine run on this grid: min spacing.

        Asks the transport loop to file energy no coarser than this grid can
        distinguish. Passing it is only worth the cost when this grid is finer than
        the transport voxels; at or above voxel resolution leave the engine's
        ``None`` default, which is cheaper and equivalent. See
        :meth:`pyradmc.backends.ref.engine.ReferenceEngine.run`.

        Half the smallest spacing: a uniform grid has one bin width, so any
        divisor of it is commensurate, and halving puts two deposits in the
        narrowest voxel. (The aliasing a non-dividing spacing causes on a *graded*
        axis is discussed in
        :func:`pyradmc.scoring.cylinder.common_bin_divisor`.)
        """
        return 0.5 * min(self.spacing)

    @property
    def upper_corner(self) -> tuple[float, float, float]:
        """Position of the upper outer corner, in cm (outside, half-open)."""
        return (
            self.origin[0] + self.shape[0] * self.spacing[0],
            self.origin[1] + self.shape[1] * self.spacing[1],
            self.origin[2] + self.shape[2] * self.spacing[2],
        )

    def contains(self, x: float, y: float, z: float) -> bool:
        """Whether the position lies inside the scoring grid (upper faces excluded)."""
        hi = self.upper_corner
        return point_inside(x, y, z, *self.origin, *hi)

    def voxel_index(self, x: float, y: float, z: float) -> tuple[int, int, int]:
        """Scoring voxel containing the position; the caller guarantees ``contains``."""
        return (
            point_axis_index(x, self.origin[0], self.spacing[0], self.shape[0]),
            point_axis_index(y, self.origin[1], self.spacing[1], self.shape[1]),
            point_axis_index(z, self.origin[2], self.spacing[2], self.shape[2]),
        )

    def flat_index(self, x: float, y: float, z: float) -> int:
        """Flat voxel index ``(ix * ny + iy) * nz + iz``, or ``-1`` when outside.

        C order, matching ``voxel_mass.reshape(-1)`` and the flat buffer the Warp
        kernels score into. Reporting "outside" in band is what lets the batched
        scorer route a deposit through one query regardless of which scoring
        geometry it was handed (see :class:`~pyradmc.scoring.cylinder.CylindricalScoringGrid`,
        which offers the same two methods over a different binning).
        """
        if not self.contains(x, y, z):
            return -1
        ix, iy, iz = self.voxel_index(x, y, z)
        return (ix * self.shape[1] + iy) * self.shape[2] + iz

n_voxels property

n_voxels: int

Number of scoring voxels.

deposit_resolution_cm property

deposit_resolution_cm: float

Suggested deposit_resolution_cm for an engine run on this grid: min spacing.

Asks the transport loop to file energy no coarser than this grid can distinguish. Passing it is only worth the cost when this grid is finer than the transport voxels; at or above voxel resolution leave the engine's None default, which is cheaper and equivalent. See :meth:pyradmc.backends.ref.engine.ReferenceEngine.run.

Half the smallest spacing: a uniform grid has one bin width, so any divisor of it is commensurate, and halving puts two deposits in the narrowest voxel. (The aliasing a non-dividing spacing causes on a graded axis is discussed in :func:pyradmc.scoring.cylinder.common_bin_divisor.)

upper_corner property

upper_corner: tuple[float, float, float]

Position of the upper outer corner, in cm (outside, half-open).

for_grid classmethod

for_grid(grid: VoxelGrid) -> ScoringGrid

Score on the transport grid itself: same geometry, mass = density * volume.

This is the engines' scoring_grid=None default. The mass is the direct per-voxel product — the identical arithmetic the scorer used before scoring grids existed — not an overlap rebin, so the default path stays byte-identical, not merely equal to rounding.

Source code in pyradmc/scoring/grid.py
@classmethod
def for_grid(cls, grid: VoxelGrid) -> ScoringGrid:
    """Score on the transport grid itself: same geometry, mass = density * volume.

    This is the engines' ``scoring_grid=None`` default. The mass is the direct
    per-voxel product — the identical arithmetic the scorer used before scoring
    grids existed — not an overlap rebin, so the default path stays
    byte-identical, not merely equal to rounding.
    """
    return cls(
        shape=grid.shape,
        spacing=grid.spacing,
        origin=grid.origin,
        voxel_mass=grid.density * grid.voxel_volume,
    )

rebin classmethod

rebin(grid: VoxelGrid, shape: tuple[int, int, int], spacing: tuple[float, float, float], origin: tuple[float, float, float]) -> ScoringGrid

Build a scoring grid of arbitrary geometry, mass by exact voxel overlap.

mass_J = sum_i rho_i * prod_axis overlap_1d over transport voxels i: exact for non-aligned and non-integer-ratio grids, and it degrades gracefully at the edges — a partially covered scoring voxel carries the mass of the covered part only, which is also the only part deposits can arrive in.

Source code in pyradmc/scoring/grid.py
@classmethod
def rebin(
    cls,
    grid: VoxelGrid,
    shape: tuple[int, int, int],
    spacing: tuple[float, float, float],
    origin: tuple[float, float, float],
) -> ScoringGrid:
    """Build a scoring grid of arbitrary geometry, mass by exact voxel overlap.

    ``mass_J = sum_i rho_i * prod_axis overlap_1d`` over transport voxels ``i``:
    exact for non-aligned and non-integer-ratio grids, and it degrades gracefully
    at the edges — a partially covered scoring voxel carries the mass of the
    covered part only, which is also the only part deposits can arrive in.
    """
    if any(n < 1 for n in shape):
        raise ValueError(f"empty scoring grid shape {shape}")
    if any(s <= 0.0 for s in spacing):
        raise ValueError(f"non-positive scoring spacing {spacing}")
    wx = _axis_overlap_lengths(
        origin[0], spacing[0], shape[0], grid.origin[0], grid.spacing[0], grid.shape[0]
    )
    wy = _axis_overlap_lengths(
        origin[1], spacing[1], shape[1], grid.origin[1], grid.spacing[1], grid.shape[1]
    )
    wz = _axis_overlap_lengths(
        origin[2], spacing[2], shape[2], grid.origin[2], grid.spacing[2], grid.shape[2]
    )
    # Separable contraction: mass_abc = sum_ijk rho_ijk * wx_ai * wy_bj * wz_ck.
    partial_x = np.tensordot(wx, grid.density, axes=(1, 0))  # (a, j, k)
    partial_xy = np.einsum("bj,ajk->abk", wy, partial_x)  # (a, b, k)
    mass = np.einsum("ck,abk->abc", wz, partial_xy)  # (a, b, c)
    return cls(shape=shape, spacing=spacing, origin=origin, voxel_mass=mass)

contains

contains(x: float, y: float, z: float) -> bool

Whether the position lies inside the scoring grid (upper faces excluded).

Source code in pyradmc/scoring/grid.py
def contains(self, x: float, y: float, z: float) -> bool:
    """Whether the position lies inside the scoring grid (upper faces excluded)."""
    hi = self.upper_corner
    return point_inside(x, y, z, *self.origin, *hi)

voxel_index

voxel_index(x: float, y: float, z: float) -> tuple[int, int, int]

Scoring voxel containing the position; the caller guarantees contains.

Source code in pyradmc/scoring/grid.py
def voxel_index(self, x: float, y: float, z: float) -> tuple[int, int, int]:
    """Scoring voxel containing the position; the caller guarantees ``contains``."""
    return (
        point_axis_index(x, self.origin[0], self.spacing[0], self.shape[0]),
        point_axis_index(y, self.origin[1], self.spacing[1], self.shape[1]),
        point_axis_index(z, self.origin[2], self.spacing[2], self.shape[2]),
    )

flat_index

flat_index(x: float, y: float, z: float) -> int

Flat voxel index (ix * ny + iy) * nz + iz, or -1 when outside.

C order, matching voxel_mass.reshape(-1) and the flat buffer the Warp kernels score into. Reporting "outside" in band is what lets the batched scorer route a deposit through one query regardless of which scoring geometry it was handed (see :class:~pyradmc.scoring.cylinder.CylindricalScoringGrid, which offers the same two methods over a different binning).

Source code in pyradmc/scoring/grid.py
def flat_index(self, x: float, y: float, z: float) -> int:
    """Flat voxel index ``(ix * ny + iy) * nz + iz``, or ``-1`` when outside.

    C order, matching ``voxel_mass.reshape(-1)`` and the flat buffer the Warp
    kernels score into. Reporting "outside" in band is what lets the batched
    scorer route a deposit through one query regardless of which scoring
    geometry it was handed (see :class:`~pyradmc.scoring.cylinder.CylindricalScoringGrid`,
    which offers the same two methods over a different binning).
    """
    if not self.contains(x, y, z):
        return -1
    ix, iy, iz = self.voxel_index(x, y, z)
    return (ix * self.shape[1] + iy) * self.shape[2] + iz

pyradmc.scoring.cylinder.CylindricalScoringGrid dataclass

Depth-by-radial-shell dose bins about a beam axis parallel to z.

Construct through :meth:for_grid (bins spanning a transport phantom, density read from it) or :meth:uniform (bins and density stated outright). Hand the engine a scorer built from the same :class:~pyradmc.geometry.grid.VoxelGrid it transports on; a scorer reaching outside that grid would divide real energy by grams that are not there, which :meth:for_grid refuses up front.

Bins follow the transport grid's half-open convention on both axes (the entrance plane and inner shell surface are inside, the exit plane and outer surface are outside), delegated to the shared scalar primitives in :mod:pyradmc.geometry.cylinder so host and kernel cannot disagree.

Attributes:

Name Type Description
axis tuple[float, float]

(x, y) of the cylinder axis in cm; the axis runs parallel to z.

depth_edges ndarray

Increasing depth boundaries in cm, n_depth + 1 of them. Bins need not be uniform: :func:graded_edges builds the fine-through-build-up, coarse-in-the-tail schedule a kernel database wants.

radial_edges ndarray

Increasing shell radii in cm, n_shells + 1 of them, starting at or above zero.

voxel_mass ndarray

Mass per bin in g, shape (n_depth, n_shells) — the annulus volume times the medium density. Named to match :class:~pyradmc.scoring.grid.ScoringGrid so the batched scorer and both engines consume either geometry through one code path.

Source code in pyradmc/scoring/cylinder.py
@dataclass(frozen=True)
class CylindricalScoringGrid:
    """Depth-by-radial-shell dose bins about a beam axis parallel to z.

    Construct through :meth:`for_grid` (bins spanning a transport phantom, density
    read from it) or :meth:`uniform` (bins and density stated outright). Hand the
    engine a scorer built from the same :class:`~pyradmc.geometry.grid.VoxelGrid`
    it transports on; a scorer reaching outside that grid would divide real energy
    by grams that are not there, which :meth:`for_grid` refuses up front.

    Bins follow the transport grid's half-open convention on both axes (the
    entrance plane and inner shell surface are inside, the exit plane and outer
    surface are outside), delegated to the shared scalar primitives in
    :mod:`pyradmc.geometry.cylinder` so host and kernel cannot disagree.

    Attributes
    ----------
    axis
        ``(x, y)`` of the cylinder axis in cm; the axis runs parallel to z.
    depth_edges
        Increasing depth boundaries in cm, ``n_depth + 1`` of them. Bins need not
        be uniform: :func:`graded_edges` builds the fine-through-build-up,
        coarse-in-the-tail schedule a kernel database wants.
    radial_edges
        Increasing shell radii in cm, ``n_shells + 1`` of them, starting at or
        above zero.
    voxel_mass
        Mass per bin in g, shape ``(n_depth, n_shells)`` — the annulus volume
        times the medium density. Named to match
        :class:`~pyradmc.scoring.grid.ScoringGrid` so the batched scorer and both
        engines consume either geometry through one code path.
    """

    axis: tuple[float, float]
    depth_edges: np.ndarray
    radial_edges: np.ndarray
    voxel_mass: np.ndarray
    _edges_squared: np.ndarray = field(init=False, repr=False, compare=False)

    def __post_init__(self) -> None:
        """Validate the binning and precompute the squared edges the lookups use."""
        depth = np.asarray(self.depth_edges, dtype=np.float64)
        if depth.ndim != 1 or depth.size < 2:
            raise ValueError("depth_edges needs at least one depth bin, i.e. two depths")
        if np.any(np.diff(depth) <= 0.0):
            raise ValueError("depth_edges must be strictly increasing")
        edges = np.asarray(self.radial_edges, dtype=np.float64)
        if edges.ndim != 1 or edges.size < 2:
            raise ValueError("radial_edges needs at least one shell, i.e. two radii")
        if np.any(edges < 0.0):
            raise ValueError("negative radial edge")
        if np.any(np.diff(edges) <= 0.0):
            raise ValueError("radial_edges must be strictly increasing")
        if self.voxel_mass.shape != (depth.size - 1, edges.size - 1):
            raise ValueError(
                f"voxel_mass shape {self.voxel_mass.shape} != "
                f"{(depth.size - 1, edges.size - 1)} (n_depth, n_shells)"
            )
        if np.any(self.voxel_mass < 0.0):
            raise ValueError("negative shell mass")
        object.__setattr__(self, "depth_edges", depth)
        object.__setattr__(self, "radial_edges", edges)
        object.__setattr__(self, "_edges_squared", edges**2)

    # --- constructors ------------------------------------------------------

    @classmethod
    def uniform(
        cls,
        density: float,
        axis: tuple[float, float],
        depth_edges: np.ndarray,
        radial_edges: np.ndarray,
    ) -> CylindricalScoringGrid:
        """Bins in a medium of stated uniform ``density`` (g/cm^3); mass analytic.

        The direct constructor, for a phantom whose density the caller already knows.
        :meth:`for_grid` is the same thing with the density taken from the transport
        grid and the coverage checked.
        """
        if density <= 0.0:
            raise ValueError(f"non-positive density {density}")
        edges = np.asarray(radial_edges, dtype=np.float64)
        if edges.ndim != 1 or edges.size < 2:
            raise ValueError("radial_edges needs at least one shell, i.e. two radii")
        if np.any(edges < 0.0):
            raise ValueError("negative radial edge")
        if np.any(np.diff(edges) <= 0.0):
            raise ValueError("radial_edges must be strictly increasing")
        depth = np.asarray(depth_edges, dtype=np.float64)
        if depth.ndim != 1 or depth.size < 2:
            raise ValueError("depth_edges needs at least one depth bin, i.e. two depths")
        if np.any(np.diff(depth) <= 0.0):
            raise ValueError("depth_edges must be strictly increasing")
        # m = rho * pi * (r_out^2 - r_in^2) * dz, with each bin's own dz: the outer
        # product of the annulus areas with the depth thicknesses.
        shell_area = math.pi * (edges[1:] ** 2 - edges[:-1] ** 2)
        mass = density * np.outer(np.diff(depth), shell_area)
        return cls(
            axis=axis,
            depth_edges=depth,
            radial_edges=edges,
            voxel_mass=mass,
        )

    @classmethod
    def for_grid(
        cls,
        grid: VoxelGrid,
        radial_edges: np.ndarray,
        axis: tuple[float, float] | None = None,
        depth_edges: np.ndarray | None = None,
    ) -> CylindricalScoringGrid:
        """Bins spanning a transport phantom, density read from it and checked.

        Defaults place the cylinder where a pencil-beam kernel run wants it: the
        axis on the phantom's lateral centre, and depth bins covering the phantom's
        full z extent at its z spacing. Either may be overridden — pass
        :func:`graded_edges` for a depth schedule that follows the build-up.

        Raises
        ------
        ValueError
            If the binned region reaches outside the transport grid (its mass would
            be fictitious), or if the medium it overlays is not uniform in density
            and material (the analytic annulus mass would then be wrong, and no
            exact separable rebin of an annulus exists — use
            :class:`~pyradmc.scoring.grid.ScoringGrid` for a heterogeneous phantom).
        """
        edges = np.asarray(radial_edges, dtype=np.float64)
        if edges.ndim != 1 or edges.size < 2:
            raise ValueError("radial_edges needs at least one shell, i.e. two radii")
        hi = grid.upper_corner
        if axis is None:
            axis = (
                0.5 * (grid.origin[0] + hi[0]),
                0.5 * (grid.origin[1] + hi[1]),
            )
        if depth_edges is None:
            n_depth = max(1, math.floor((hi[2] - grid.origin[2]) / grid.spacing[2]))
            depth_edges = grid.origin[2] + grid.spacing[2] * np.arange(
                n_depth + 1, dtype=np.float64
            )
        depth = np.asarray(depth_edges, dtype=np.float64)
        if depth.ndim != 1 or depth.size < 2:
            raise ValueError("depth_edges needs at least one depth bin, i.e. two depths")

        r_max = float(edges[-1])
        depth_origin = float(depth[0])
        depth_hi = float(depth[-1])
        if (
            axis[0] - r_max < grid.origin[0]
            or axis[0] + r_max > hi[0]
            or axis[1] - r_max < grid.origin[1]
            or axis[1] + r_max > hi[1]
            or depth_origin < grid.origin[2]
            or depth_hi > hi[2]
        ):
            raise ValueError(
                f"the scoring cylinder (axis {axis}, r_max {r_max} cm, depth "
                f"[{depth_origin}, {depth_hi}) cm) reaches outside the transport grid "
                f"{grid.origin} to {hi}; its shells would claim mass the phantom does "
                "not have"
            )

        density = _uniform_medium_density(grid, axis, r_max, depth_origin, depth_hi)
        return cls.uniform(
            density=density,
            axis=axis,
            depth_edges=depth,
            radial_edges=edges,
        )

    # --- geometry ----------------------------------------------------------

    @property
    def n_shells(self) -> int:
        """Number of radial shells."""
        return self.radial_edges.size - 1

    @property
    def n_depth(self) -> int:
        """Number of depth bins."""
        return self.depth_edges.size - 1

    @property
    def depth_origin(self) -> float:
        """Position of the entrance plane of the first depth bin, in cm."""
        return float(self.depth_edges[0])

    @property
    def depth_thickness(self) -> np.ndarray:
        """Thickness of each depth bin in cm, ``n_depth`` of them.

        The per-bin ``dz``. Divide an energy-per-bin by this to compare graded bins
        against each other on a per-centimetre footing.
        """
        thickness: np.ndarray = np.diff(self.depth_edges)
        return thickness

    @property
    def shape(self) -> tuple[int, int]:
        """Bin counts as ``(n_depth, n_shells)`` — the shape of ``dose``."""
        return (self.n_depth, self.n_shells)

    @property
    def n_voxels(self) -> int:
        """Number of bins; the length of the flat buffer a device scores into."""
        return self.n_depth * self.n_shells

    @property
    def deposit_resolution_cm(self) -> float:
        """Suggested ``deposit_resolution_cm`` for an engine run on this binning.

        A pencil kernel bins far below the transport voxel scale, which is exactly
        where a half-substep filed as one midpoint deposit prints the voxel lattice
        onto the dose profile. Passing this asks the transport loop to file energy
        finely enough for these bins to be meaningful.

        **The depth axis, not the radial one.** Spreading subdivides a substep
        *along its own direction*, and for the beam this geometry describes that
        direction is the depth axis. The radial profile is resolved by the
        transverse spread of many histories, not by subdividing one step — so the
        innermost geometric shell, which can be micrometres wide, would demand
        hundreds of deposits per step and buy nothing.

        **Half the largest length that divides every depth bin width**, not simply
        the finest bin. A spacing that does not divide the bin width aliases against
        it, and because both are locked to the voxel lattice the beat stands still
        instead of averaging out — see :func:`common_bin_divisor` for the measured
        sizes. Halving it puts at least two deposits in the narrowest bin while
        staying commensurate with all of them.
        """
        widths = self.depth_thickness
        floor = float(widths.min()) / 16.0
        return 0.5 * common_bin_divisor(widths, floor)

    @property
    def depth_upper(self) -> float:
        """Position of the exit plane of the last depth bin, in cm (outside)."""
        return float(self.depth_edges[-1])

    @property
    def depth_centers(self) -> np.ndarray:
        """Depth bin midpoints in cm — the abscissa a depth-dose curve is plotted on."""
        centers: np.ndarray = 0.5 * (self.depth_edges[:-1] + self.depth_edges[1:])
        return centers

    @property
    def radial_centers(self) -> np.ndarray:
        """Area-weighted shell radii in cm: ``sqrt((r_in^2 + r_out^2) / 2)``.

        The radius that halves each shell's area, which is where a smoothly varying
        radial quantity averages to its shell mean — not the arithmetic midpoint,
        which biases outward-falling profiles inward on wide shells.
        """
        centers: np.ndarray = np.sqrt(0.5 * (self._edges_squared[:-1] + self._edges_squared[1:]))
        return centers

    @property
    def shell_volume(self) -> np.ndarray:
        """Volume of each bin in cm^3, shape ``(n_depth, n_shells)``."""
        area = math.pi * (self._edges_squared[1:] - self._edges_squared[:-1])
        return np.outer(self.depth_thickness, area)

    def contains(self, x: float, y: float, z: float) -> bool:
        """Whether the position lies inside the binned region (upper faces excluded)."""
        return cylinder_contains(
            x,
            y,
            z,
            self.axis[0],
            self.axis[1],
            self.depth_origin,
            self.depth_upper,
            float(self._edges_squared[0]),
            float(self._edges_squared[-1]),
        )

    def voxel_index(self, x: float, y: float, z: float) -> tuple[int, int]:
        """``(depth bin, shell)`` containing the position; caller guarantees ``contains``."""
        iz = edge_bin_index(z, self.depth_edges, self.n_depth)
        ir = edge_bin_index(
            cylinder_radius_squared(x, y, self.axis[0], self.axis[1]),
            self._edges_squared,
            self.n_shells,
        )
        return (iz, ir)

    def flat_index(self, x: float, y: float, z: float) -> int:
        """Flat bin index ``iz * n_shells + ir``, or ``-1`` when outside.

        C order, matching ``voxel_mass.reshape(-1)`` and the flat buffer the Warp
        kernels score into. Outside is reported in band so that a caller needs one
        query per deposit rather than a separate containment test.
        """
        if not self.contains(x, y, z):
            return -1
        iz, ir = self.voxel_index(x, y, z)
        return iz * self.n_shells + ir

n_shells property

n_shells: int

Number of radial shells.

n_depth property

n_depth: int

Number of depth bins.

depth_origin property

depth_origin: float

Position of the entrance plane of the first depth bin, in cm.

depth_thickness property

depth_thickness: ndarray

Thickness of each depth bin in cm, n_depth of them.

The per-bin dz. Divide an energy-per-bin by this to compare graded bins against each other on a per-centimetre footing.

shape property

shape: tuple[int, int]

Bin counts as (n_depth, n_shells) — the shape of dose.

n_voxels property

n_voxels: int

Number of bins; the length of the flat buffer a device scores into.

deposit_resolution_cm property

deposit_resolution_cm: float

Suggested deposit_resolution_cm for an engine run on this binning.

A pencil kernel bins far below the transport voxel scale, which is exactly where a half-substep filed as one midpoint deposit prints the voxel lattice onto the dose profile. Passing this asks the transport loop to file energy finely enough for these bins to be meaningful.

The depth axis, not the radial one. Spreading subdivides a substep along its own direction, and for the beam this geometry describes that direction is the depth axis. The radial profile is resolved by the transverse spread of many histories, not by subdividing one step — so the innermost geometric shell, which can be micrometres wide, would demand hundreds of deposits per step and buy nothing.

Half the largest length that divides every depth bin width, not simply the finest bin. A spacing that does not divide the bin width aliases against it, and because both are locked to the voxel lattice the beat stands still instead of averaging out — see :func:common_bin_divisor for the measured sizes. Halving it puts at least two deposits in the narrowest bin while staying commensurate with all of them.

depth_upper property

depth_upper: float

Position of the exit plane of the last depth bin, in cm (outside).

depth_centers property

depth_centers: ndarray

Depth bin midpoints in cm — the abscissa a depth-dose curve is plotted on.

radial_centers property

radial_centers: ndarray

Area-weighted shell radii in cm: sqrt((r_in^2 + r_out^2) / 2).

The radius that halves each shell's area, which is where a smoothly varying radial quantity averages to its shell mean — not the arithmetic midpoint, which biases outward-falling profiles inward on wide shells.

shell_volume property

shell_volume: ndarray

Volume of each bin in cm^3, shape (n_depth, n_shells).

uniform classmethod

uniform(density: float, axis: tuple[float, float], depth_edges: ndarray, radial_edges: ndarray) -> CylindricalScoringGrid

Bins in a medium of stated uniform density (g/cm^3); mass analytic.

The direct constructor, for a phantom whose density the caller already knows. :meth:for_grid is the same thing with the density taken from the transport grid and the coverage checked.

Source code in pyradmc/scoring/cylinder.py
@classmethod
def uniform(
    cls,
    density: float,
    axis: tuple[float, float],
    depth_edges: np.ndarray,
    radial_edges: np.ndarray,
) -> CylindricalScoringGrid:
    """Bins in a medium of stated uniform ``density`` (g/cm^3); mass analytic.

    The direct constructor, for a phantom whose density the caller already knows.
    :meth:`for_grid` is the same thing with the density taken from the transport
    grid and the coverage checked.
    """
    if density <= 0.0:
        raise ValueError(f"non-positive density {density}")
    edges = np.asarray(radial_edges, dtype=np.float64)
    if edges.ndim != 1 or edges.size < 2:
        raise ValueError("radial_edges needs at least one shell, i.e. two radii")
    if np.any(edges < 0.0):
        raise ValueError("negative radial edge")
    if np.any(np.diff(edges) <= 0.0):
        raise ValueError("radial_edges must be strictly increasing")
    depth = np.asarray(depth_edges, dtype=np.float64)
    if depth.ndim != 1 or depth.size < 2:
        raise ValueError("depth_edges needs at least one depth bin, i.e. two depths")
    if np.any(np.diff(depth) <= 0.0):
        raise ValueError("depth_edges must be strictly increasing")
    # m = rho * pi * (r_out^2 - r_in^2) * dz, with each bin's own dz: the outer
    # product of the annulus areas with the depth thicknesses.
    shell_area = math.pi * (edges[1:] ** 2 - edges[:-1] ** 2)
    mass = density * np.outer(np.diff(depth), shell_area)
    return cls(
        axis=axis,
        depth_edges=depth,
        radial_edges=edges,
        voxel_mass=mass,
    )

for_grid classmethod

for_grid(grid: VoxelGrid, radial_edges: ndarray, axis: tuple[float, float] | None = None, depth_edges: ndarray | None = None) -> CylindricalScoringGrid

Bins spanning a transport phantom, density read from it and checked.

Defaults place the cylinder where a pencil-beam kernel run wants it: the axis on the phantom's lateral centre, and depth bins covering the phantom's full z extent at its z spacing. Either may be overridden — pass :func:graded_edges for a depth schedule that follows the build-up.

Raises:

Type Description
ValueError

If the binned region reaches outside the transport grid (its mass would be fictitious), or if the medium it overlays is not uniform in density and material (the analytic annulus mass would then be wrong, and no exact separable rebin of an annulus exists — use :class:~pyradmc.scoring.grid.ScoringGrid for a heterogeneous phantom).

Source code in pyradmc/scoring/cylinder.py
@classmethod
def for_grid(
    cls,
    grid: VoxelGrid,
    radial_edges: np.ndarray,
    axis: tuple[float, float] | None = None,
    depth_edges: np.ndarray | None = None,
) -> CylindricalScoringGrid:
    """Bins spanning a transport phantom, density read from it and checked.

    Defaults place the cylinder where a pencil-beam kernel run wants it: the
    axis on the phantom's lateral centre, and depth bins covering the phantom's
    full z extent at its z spacing. Either may be overridden — pass
    :func:`graded_edges` for a depth schedule that follows the build-up.

    Raises
    ------
    ValueError
        If the binned region reaches outside the transport grid (its mass would
        be fictitious), or if the medium it overlays is not uniform in density
        and material (the analytic annulus mass would then be wrong, and no
        exact separable rebin of an annulus exists — use
        :class:`~pyradmc.scoring.grid.ScoringGrid` for a heterogeneous phantom).
    """
    edges = np.asarray(radial_edges, dtype=np.float64)
    if edges.ndim != 1 or edges.size < 2:
        raise ValueError("radial_edges needs at least one shell, i.e. two radii")
    hi = grid.upper_corner
    if axis is None:
        axis = (
            0.5 * (grid.origin[0] + hi[0]),
            0.5 * (grid.origin[1] + hi[1]),
        )
    if depth_edges is None:
        n_depth = max(1, math.floor((hi[2] - grid.origin[2]) / grid.spacing[2]))
        depth_edges = grid.origin[2] + grid.spacing[2] * np.arange(
            n_depth + 1, dtype=np.float64
        )
    depth = np.asarray(depth_edges, dtype=np.float64)
    if depth.ndim != 1 or depth.size < 2:
        raise ValueError("depth_edges needs at least one depth bin, i.e. two depths")

    r_max = float(edges[-1])
    depth_origin = float(depth[0])
    depth_hi = float(depth[-1])
    if (
        axis[0] - r_max < grid.origin[0]
        or axis[0] + r_max > hi[0]
        or axis[1] - r_max < grid.origin[1]
        or axis[1] + r_max > hi[1]
        or depth_origin < grid.origin[2]
        or depth_hi > hi[2]
    ):
        raise ValueError(
            f"the scoring cylinder (axis {axis}, r_max {r_max} cm, depth "
            f"[{depth_origin}, {depth_hi}) cm) reaches outside the transport grid "
            f"{grid.origin} to {hi}; its shells would claim mass the phantom does "
            "not have"
        )

    density = _uniform_medium_density(grid, axis, r_max, depth_origin, depth_hi)
    return cls.uniform(
        density=density,
        axis=axis,
        depth_edges=depth,
        radial_edges=edges,
    )

contains

contains(x: float, y: float, z: float) -> bool

Whether the position lies inside the binned region (upper faces excluded).

Source code in pyradmc/scoring/cylinder.py
def contains(self, x: float, y: float, z: float) -> bool:
    """Whether the position lies inside the binned region (upper faces excluded)."""
    return cylinder_contains(
        x,
        y,
        z,
        self.axis[0],
        self.axis[1],
        self.depth_origin,
        self.depth_upper,
        float(self._edges_squared[0]),
        float(self._edges_squared[-1]),
    )

voxel_index

voxel_index(x: float, y: float, z: float) -> tuple[int, int]

(depth bin, shell) containing the position; caller guarantees contains.

Source code in pyradmc/scoring/cylinder.py
def voxel_index(self, x: float, y: float, z: float) -> tuple[int, int]:
    """``(depth bin, shell)`` containing the position; caller guarantees ``contains``."""
    iz = edge_bin_index(z, self.depth_edges, self.n_depth)
    ir = edge_bin_index(
        cylinder_radius_squared(x, y, self.axis[0], self.axis[1]),
        self._edges_squared,
        self.n_shells,
    )
    return (iz, ir)

flat_index

flat_index(x: float, y: float, z: float) -> int

Flat bin index iz * n_shells + ir, or -1 when outside.

C order, matching voxel_mass.reshape(-1) and the flat buffer the Warp kernels score into. Outside is reported in band so that a caller needs one query per deposit rather than a separate containment test.

Source code in pyradmc/scoring/cylinder.py
def flat_index(self, x: float, y: float, z: float) -> int:
    """Flat bin index ``iz * n_shells + ir``, or ``-1`` when outside.

    C order, matching ``voxel_mass.reshape(-1)`` and the flat buffer the Warp
    kernels score into. Outside is reported in band so that a caller needs one
    query per deposit rather than a separate containment test.
    """
    if not self.contains(x, y, z):
        return -1
    iz, ir = self.voxel_index(x, y, z)
    return iz * self.n_shells + ir

pyradmc.scoring.cylinder.uniform_edges

uniform_edges(r_max: float, n_shells: int) -> np.ndarray

Equal-thickness shell edges from 0 to r_max: n_shells + 1 radii in cm.

Simple, and adequate when the quantity of interest is integral rather than the near-axis gradient. For a pencil kernel prefer :func:geometric_edges, which spends its bins where the dose actually varies.

Source code in pyradmc/scoring/cylinder.py
def uniform_edges(r_max: float, n_shells: int) -> np.ndarray:
    """Equal-thickness shell edges from 0 to ``r_max``: ``n_shells + 1`` radii in cm.

    Simple, and adequate when the quantity of interest is integral rather than the
    near-axis gradient. For a pencil kernel prefer :func:`geometric_edges`, which
    spends its bins where the dose actually varies.
    """
    if n_shells < 1:
        raise ValueError(f"a cylindrical scorer needs at least one shell, got {n_shells}")
    if r_max <= 0.0:
        raise ValueError(f"r_max must be positive, got {r_max}")
    return np.linspace(0.0, r_max, n_shells + 1, dtype=np.float64)

pyradmc.scoring.cylinder.geometric_edges

geometric_edges(r_max: float, n_shells: int, r_min: float) -> np.ndarray

Shell edges that resolve the core: a central disc, then equal ratios to r_max.

A pencil-beam kernel spans several decades in dose between the axis and the scatter tail, and essentially all of the structure sits in the first few millimetres. Equal-ratio shells put a constant relative resolution everywhere, which is the natural binning for a quantity that falls roughly as a power law.

Geometric spacing cannot start at zero, so the innermost bin is the full disc [0, r_min) and the remaining n_shells - 1 bins are geometric from r_min to r_max. Returns n_shells + 1 radii in cm.

Bin choice is deliberately the caller's: it is a readout resolution, not an accuracy-defining default (AGENTS.md 2.8), and no value of it changes transport.

Source code in pyradmc/scoring/cylinder.py
def geometric_edges(r_max: float, n_shells: int, r_min: float) -> np.ndarray:
    """Shell edges that resolve the core: a central disc, then equal ratios to ``r_max``.

    A pencil-beam kernel spans several decades in dose between the axis and the
    scatter tail, and essentially all of the structure sits in the first few
    millimetres. Equal-ratio shells put a constant *relative* resolution everywhere,
    which is the natural binning for a quantity that falls roughly as a power law.

    Geometric spacing cannot start at zero, so the innermost bin is the full disc
    ``[0, r_min)`` and the remaining ``n_shells - 1`` bins are geometric from
    ``r_min`` to ``r_max``. Returns ``n_shells + 1`` radii in cm.

    Bin choice is deliberately the caller's: it is a readout resolution, not an
    accuracy-defining default (AGENTS.md 2.8), and no value of it changes transport.
    """
    if n_shells < 2:
        raise ValueError(
            f"geometric edges need at least two shells (a central disc plus one "
            f"geometric shell), got {n_shells}"
        )
    if r_max <= 0.0:
        raise ValueError(f"r_max must be positive, got {r_max}")
    if not 0.0 < r_min < r_max:
        raise ValueError(f"need 0 < r_min < r_max, got r_min={r_min}, r_max={r_max}")
    return np.concatenate(
        ([0.0], np.geomspace(r_min, r_max, n_shells, dtype=np.float64)),
    )

pyradmc.scoring.cylinder.graded_edges

graded_edges(segments: Sequence[tuple[float, float, float]]) -> np.ndarray

Edges from contiguous (start, stop, step) segments of differing resolution.

The depth binning a pencil-beam kernel database wants: fine through the build-up region, where the curve has all its structure, and coarse in the slowly varying tail, so that bins are spent where the gradient is rather than uniformly. For example, the classic 0-32 cm water schedule

[(0.0, 0.5, 0.010), (0.5, 2.0, 0.025), (2.0, 6.0, 0.25), (6.0, 32.0, 1.0)]

yields 152 bins where a uniform 0.010 cm grid would need 3200.

Every segment must start where the previous one stopped and span a whole number of steps. A segment that does not is refused rather than rounded: absorbing the remainder into a short last bin would misplace every edge downstream of it, and the bin a dose lands in is not a detail that should be decided silently.

Source code in pyradmc/scoring/cylinder.py
def graded_edges(segments: Sequence[tuple[float, float, float]]) -> np.ndarray:
    """Edges from contiguous ``(start, stop, step)`` segments of differing resolution.

    The depth binning a pencil-beam kernel database wants: fine through the build-up
    region, where the curve has all its structure, and coarse in the slowly varying
    tail, so that bins are spent where the gradient is rather than uniformly. For
    example, the classic 0-32 cm water schedule

    ``[(0.0, 0.5, 0.010), (0.5, 2.0, 0.025), (2.0, 6.0, 0.25), (6.0, 32.0, 1.0)]``

    yields 152 bins where a uniform 0.010 cm grid would need 3200.

    Every segment must start where the previous one stopped and span a whole number
    of steps. A segment that does not is refused rather than rounded: absorbing the
    remainder into a short last bin would misplace every edge downstream of it, and
    the bin a dose lands in is not a detail that should be decided silently.
    """
    if not segments:
        raise ValueError("need at least one segment")
    edges: list[float] = [float(segments[0][0])]
    for start, stop, step in segments:
        if step <= 0.0:
            raise ValueError(f"segment step must be positive, got {step}")
        if abs(start - edges[-1]) > 1e-12 * max(1.0, abs(start)):
            raise ValueError(
                f"segments must be contiguous: segment starting at {start} does not "
                f"continue from {edges[-1]}"
            )
        if stop <= start:
            raise ValueError(f"segment stop {stop} must exceed start {start}")
        n = round((stop - start) / step)
        if abs(start + n * step - stop) > 1e-9 * max(1.0, abs(stop)):
            raise ValueError(f"segment {start} to {stop} is not a whole number of steps of {step}")
        edges.extend(start + step * k for k in range(1, n + 1))
    edges[-1] = float(segments[-1][1])  # exact endpoint, not an accumulated sum
    return np.asarray(edges, dtype=np.float64)

pyradmc.scoring.cylinder.common_bin_divisor

common_bin_divisor(widths: ndarray, floor: float) -> float

Largest length that divides every width in widths, or floor if none does.

Why divisibility matters: sub-substep deposits are laid at a fixed spacing from the step start, and step starts are pinned to transport voxel faces, so the whole point set is locked to the voxel lattice. If the spacing does not divide the scoring bin width, the two beat against each other and the fixed phase turns that beat into a standing ripple rather than noise. Measured on a 6 / 15 MeV pencil beam over 0.025 cm bins: a 0.010 cm spacing (ratio 2.5) leaves 5.7 / 12.7 percent peak-to-trough, while 0.005 cm (ratio 5) leaves 1.3 / 1.7 percent.

Euclid on floats, with a relative tolerance, because bin schedules are built from decimal step sizes and their exact float representations are not commensurate. Genuinely incommensurable widths drive the divisor toward zero; floor bounds that, at the cost of leaving some ripple, since arbitrarily fine spacing is arbitrarily expensive.

Source code in pyradmc/scoring/cylinder.py
def common_bin_divisor(widths: np.ndarray, floor: float) -> float:
    """Largest length that divides every width in ``widths``, or ``floor`` if none does.

    Why divisibility matters: sub-substep deposits are laid at a *fixed* spacing from
    the step start, and step starts are pinned to transport voxel faces, so the whole
    point set is locked to the voxel lattice. If the spacing does not divide the
    scoring bin width, the two beat against each other and the fixed phase turns that
    beat into a standing ripple rather than noise. Measured on a 6 / 15 MeV pencil
    beam over 0.025 cm bins: a 0.010 cm spacing (ratio 2.5) leaves 5.7 / 12.7 percent
    peak-to-trough, while 0.005 cm (ratio 5) leaves 1.3 / 1.7 percent.

    Euclid on floats, with a relative tolerance, because bin schedules are built from
    decimal step sizes and their exact float representations are not commensurate.
    Genuinely incommensurable widths drive the divisor toward zero; ``floor`` bounds
    that, at the cost of leaving some ripple, since arbitrarily fine spacing is
    arbitrarily expensive.
    """
    distinct = np.unique(np.round(np.asarray(widths, dtype=np.float64) / floor)) * floor
    g = float(distinct[0])
    for value in distinct[1:]:
        a, c = g, float(value)
        while c > floor:
            a, c = c, a - c * math.floor(a / c + 1.0e-9)
        g = a
        if g < floor:
            return floor
    return max(g, floor)

Cross-sections and materials

pyradmc.data.interface.CrossSectionSource

Bases: ABC

Abstract source of interaction data for photons and electrons.

Implementations are constructed once, on the host, and then expose flat array handles to the kernels. The abstract methods below are the host-side query API used for construction, validation, and the reference backend. Kernel-side access goes through the flattened tables that build_tables returns.

Source code in pyradmc/data/interface.py
class CrossSectionSource(ABC):
    """Abstract source of interaction data for photons and electrons.

    Implementations are constructed once, on the host, and then expose flat array
    handles to the kernels. The abstract methods below are the *host-side* query
    API used for construction, validation, and the reference backend. Kernel-side
    access goes through the flattened tables that ``build_tables`` returns.
    """

    @property
    def n_materials(self) -> int:
        """How many registry materials this source can answer for.

        Tables are flattened for material indices ``0 .. n_materials - 1``
        (:func:`~pyradmc.data.tables.build_cross_section_tables` sizes its rows by
        this), and a query beyond it must raise rather than approximate: a source
        silently answering for a material it has no data for is a silent transport
        bias. The default — the full registry — is for material-*independent*
        sources (test instruments); a calibrated source overrides it with its real
        coverage (the analytic source: water only; the tabulated source: the
        compiled row count).
        """
        from pyradmc.data.materials import MATERIALS

        return len(MATERIALS)

    @property
    def provenance(self) -> str:
        """Human-readable citation for the data this source answers from.

        Carried into every result's
        :class:`~pyradmc.backends.results.RunProvenance` so an archived dose says
        which cross-sections produced it — the single most consequential thing about
        a run and the one least recoverable from the dose array. The default names
        the class, which is honest but uninformative; a source built from compiled
        data overrides it with that data's own provenance string.
        """
        return type(self).__name__

    # -- photons ------------------------------------------------------------

    @abstractmethod
    def mu_over_rho(self, energy: float, material: int, process: int) -> float:
        """Mass attenuation coefficient for one process, in cm^2/g.

        Parameters
        ----------
        energy
            Photon energy in MeV.
        material
            Material index into the material table.
        process
            One of the :class:`PhotonProcess` constants.
        """

    @abstractmethod
    def mu_over_rho_total(self, energy: float, material: int) -> float:
        """Total mass attenuation coefficient, in cm^2/g.

        Must equal the sum over enabled processes. This redundancy is deliberate:
        it is a contract test (``tests/unit/test_xs_contract.py``).
        """

    @abstractmethod
    def majorant(self, energy: float) -> float:
        """Woodcock majorant: the maximum macroscopic total cross-section, in 1/cm.

        Taken over all materials and densities present in the geometry, at the given
        energy. Delta (fictitious) scattering makes up the difference. The majorant
        must never be exceeded by any real macroscopic cross-section in the geometry;
        this is a contract test, and violating it silently biases the transport.

        See Woodcock et al. (1965), ANL-7050.
        """

    def sample_coherent_cos_theta(self, energy: float, material: int, rng_state: object) -> float:
        """Sample the coherent (Rayleigh) polar scattering cosine.

        The default is the Thomson distribution (zero-momentum-transfer, flat form
        factor); a source with real atomic form-factor data (the tabulated backend)
        overrides this to sample the forward-peaked coherent distribution. Kept on the
        source, not as a free function, because the angular shape *is* cross-section data
        (AGENTS.md 2.6): the sampling math lives in :mod:`pyradmc.physics.rayleigh`, the
        data that selects it lives here.
        """
        from pyradmc.physics.rayleigh import sample_rayleigh_cos_theta

        return sample_rayleigh_cos_theta(rng_state)

    def coherent_cumulative(
        self, x_grid: npt.NDArray[np.float64], material: int
    ) -> npt.NDArray[np.float64]:
        r"""Return the coherent form-factor cumulative ``A(x)=\int_0^x F^2 x' dx'`` on ``x_grid``.

        This is the flattened, kernel-consumable face of :meth:`sample_coherent_cos_theta`:
        :func:`~pyradmc.data.tables.build_cross_section_tables` calls it per material to
        fill the table the Warp kernel inverts. The default is the flat form factor,
        ``A(x) = x^2/2``, which the sampler inverts to the Thomson distribution — so an
        analytic (zero-coherent) source needs no override, and a form-factor source
        (tabulated) resamples its compiled cumulative onto ``x_grid``.
        """
        return 0.5 * np.asarray(x_grid, dtype=np.float64) ** 2

    # -- electrons ----------------------------------------------------------

    @abstractmethod
    def restricted_stopping_power(self, energy: float, material: int, delta_cut: float) -> float:
        """Restricted collision stopping power, in MeV cm^2/g.

        Energy losses above ``delta_cut`` are excluded, being handled explicitly as
        discrete Moller (or Bhabha) events in the Class II scheme.

        Berger-Seltzer formulation; see ICRU Report 37 (1984).
        """

    @abstractmethod
    def radiative_stopping_power(self, energy: float, material: int) -> float:
        """Radiative (bremsstrahlung) stopping power, in MeV cm^2/g."""

    @abstractmethod
    def moller_cross_section(self, energy: float, material: int, delta_cut: float) -> float:
        """Restricted Moller cross-section per unit mass, in cm^2/g.

        Total cross-section for a discrete knock-on collision transferring more than
        ``delta_cut`` (MeV, kinetic) to a delta ray. Zero when ``energy`` is at or
        below ``2 * delta_cut``: by indistinguishability the delta is the *lower*
        energy outgoing electron, so it can carry at most half the kinetic energy.

        Consistency contract (tested): the energy moment of the Moller differential
        cross-section above ``delta_cut`` equals the difference between the
        unrestricted and restricted collision stopping powers. Moller (1932),
        doi:10.1002/andp.19324060506.
        """

    @abstractmethod
    def csda_range(self, energy: float, material: int) -> float:
        """Continuous-slowing-down-approximation range, in g/cm^2.

        Used for range rejection. An overestimate is safe (it rejects less); an
        underestimate biases the dose. Implementations must document which side they
        err on.
        """

    @abstractmethod
    def scattering_power(self, energy: float, material: int, delta_cut: float) -> float:
        """Mass angular scattering power, in rad^2 cm^2/g.

        Drives the multiple-elastic-scattering hinge deflection: ``T rho s`` is
        the small-step ``<theta^2>`` handed to the sampler. Because
        Goudsmit-Saunderson pins ``<cos theta> = exp(-s N sigma_tr)`` exactly,
        this must be the *first transport moment* ``2 (N_A/A) sigma_el G_1`` of
        the screened scattering law — a core-width fit such as Highland's is not the same
        quantity and under-scatters at high energy (the analytic source's
        docstring records the approximation). ``delta_cut`` partitions the
        electron-electron moment: transfers below it remain condensed here,
        while the moment of above-cutoff Moller events is excluded because those
        deflections are transported explicitly by the Class-II loop.
        """

    def elastic_screening(self, energy: float, material: int) -> float:
        r"""Moliere screening parameter of the elastic scattering law, dimensionless.

        Where :meth:`scattering_power` fixes the *strength* of multiple
        scattering, this fixes the *shape*: it is the parameter of the
        screened-Rutherford single-scattering law whose Legendre moments drive
        the Goudsmit-Saunderson angular distribution
        (:mod:`pyradmc.data.goudsmit_saunderson`). The two are consistent by
        construction — the elastic cross-section is back-derived from
        ``T = 2 (N_A/M) sigma_el G_1(eta)`` — so the GS mean-square deflection
        over a short step reproduces the Fermi-Eyges ``T rho s`` exactly. That
        anchoring is a contract test
        (``tests/unit/test_elastic_screening.py``): it is what makes GS a
        refinement of the shipped hinge rather than a rescaling of it.

        Concrete on the interface, computed from the material's elemental
        composition, so no implementation can silently omit it and the analytic
        and tabulated backends cannot drift apart on the angular shape. A source
        carrying real elastic differential data may override it.
        """
        from pyradmc.data.goudsmit_saunderson import moliere_screening
        from pyradmc.data.materials import MATERIALS

        if not 0 <= material < self.n_materials:
            raise ValueError(f"material index {material} beyond this source")
        return moliere_screening(MATERIALS[material].composition, energy)

    def sample_gs_cos_theta(
        self, mean_square_angle: float, energy: float, material: int, rng_state: object
    ) -> float:
        """Sample the Goudsmit-Saunderson multiple-scattering deflection cosine.

        The exact multiple-scattering angle for the substep, in place of the
        small-angle Gaussian of :func:`pyradmc.physics.msc.sample_hinge_cos_theta`.
        Takes the *same* ``mean_square_angle = T rho s`` the Gaussian hinge takes
        and consumes the same single uniform, so the two are drop-in
        alternatives that do not shift the random stream relative to each other —
        which is what lets a transport comparison isolate the angular model.

        Kept on the source rather than as a free function for the reason the
        coherent sampler is (see :meth:`sample_coherent_cos_theta`): the angular
        shape *is* cross-section data. The sampling math lives in
        :mod:`pyradmc.physics.gs`; the table that selects it lives here.

        Tables are memoized on a log-spaced ``(eta, <theta^2>)`` grid. Binning is
        safe because the table is normalized in a scaled deflection: the
        rescaling by this call's exact ``<1 - cos theta>`` restores the first
        moment exactly, so the grid resolution perturbs only the shape.
        """
        from pyradmc.data.goudsmit_saunderson import gs_scaled_deflection_table
        from pyradmc.physics.gs import sample_gs_cos_theta_bilinear

        if mean_square_angle <= 0.0:
            return 1.0
        eta = self.elastic_screening(energy, material)

        cache: dict[tuple[int, int], npt.NDArray[np.float64]] | None = getattr(
            self, "_gs_table_cache", None
        )
        if cache is None:
            cache = {}
            self._gs_table_cache = cache

        # Grid in log space: both parameters span decades over the transported
        # range (eta ~ 3, <theta^2> ~ 2), and the distribution varies smoothly
        # in their logarithms. The lookup interpolates between the four
        # bracketing nodes rather than snapping to the nearest, because the
        # shape is irreducibly two-dimensional and refining a nearest-bin grid
        # to the same fidelity costs quadratically more table.
        fx = math.log(eta) * _GS_BINS_PER_LOG
        fy = math.log(max(mean_square_angle, _GS_THETA2_MIN)) * _GS_BINS_PER_LOG
        ix, iy = math.floor(fx), math.floor(fy)

        def row(i: int, j: int) -> npt.NDArray[np.float64]:
            table = cache.get((i, j))
            if table is None:
                table = gs_scaled_deflection_table(
                    math.exp(i / _GS_BINS_PER_LOG),
                    math.exp(j / _GS_BINS_PER_LOG),
                    n_u=_GS_TABLE_NODES,
                )
                cache[(i, j)] = table
            return table

        return sample_gs_cos_theta_bilinear(
            row(ix, iy),
            row(ix + 1, iy),
            row(ix, iy + 1),
            row(ix + 1, iy + 1),
            fx - ix,
            fy - iy,
            _GS_TABLE_NODES,
            mean_square_angle,
            rng_state,
        )

    # -- restricted-collision range (concrete: derived from the queries above) --

    def restricted_range(self, energy: float, material: int, delta_cut: float) -> float:
        r"""Restricted-collision range down to ``delta_cut``, in g/cm^2.

        .. math::

            r(E) = \int_{\Delta}^{E} \frac{dE'}{S_{col}(E', \Delta)}

        with the *restricted collision* stopping power of
        :meth:`restricted_stopping_power` — radiative and discrete-Moller losses
        are booked separately by the Class II scheme, so this is exactly the mass
        path over which the transport loop's continuous loss takes ``E`` to the
        cutoff. Together with :meth:`energy_after_mass_path` it defines the
        exact-energy-loss substep (DPM; Sempau et al. 2000,
        doi:10.1088/0031-9155/45/8/315), replacing the first-order
        ``S(E_start) * rho * s`` linearization.

        Concrete on the interface: implementations answer through
        :meth:`restricted_stopping_power`, so the range can never disagree with
        the stopping power it integrates (trapezoid on a dense log grid, cached
        per ``(material, delta_cut)``; node count pinned by the derivative and
        round-trip tests).
        """
        log_e, cumulative = self._range_grid(material, delta_cut)
        e = min(max(energy, delta_cut), _RANGE_GRID_E_MAX)
        return float(np.interp(np.log(e), log_e, cumulative))

    def energy_after_mass_path(
        self, energy: float, material: int, delta_cut: float, mass_path: float
    ) -> float:
        """Energy after a continuous-loss mass path, ``r^-1(r(E) - mass_path)``.

        Clamped to ``[delta_cut, energy]``: a path at or beyond the remaining
        range returns exactly ``delta_cut`` (the loop's range-out branch), and
        interpolation wiggle can never *gain* energy.
        """
        log_e, cumulative = self._range_grid(material, delta_cut)
        remaining = self.restricted_range(energy, material, delta_cut) - mass_path
        if remaining <= 0.0:
            return delta_cut
        e_end = float(np.exp(np.interp(remaining, cumulative, log_e)))
        return min(max(e_end, delta_cut), energy)

    def _range_grid(
        self, material: int, delta_cut: float
    ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
        """Return the cached ``(log E nodes, cumulative range)`` grid for one key."""
        cache: (
            dict[tuple[int, float], tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]] | None
        ) = getattr(self, "_restricted_range_cache", None)
        if cache is None:
            cache = {}
            self._restricted_range_cache = cache
        key = (material, delta_cut)
        grids = cache.get(key)
        if grids is None:
            energies = np.geomspace(delta_cut, _RANGE_GRID_E_MAX, _RANGE_GRID_POINTS)
            inv_s = np.array(
                [
                    1.0 / self.restricted_stopping_power(float(e), material, delta_cut)
                    for e in energies
                ]
            )
            cumulative = np.concatenate(
                ([0.0], np.cumsum(np.diff(energies) * 0.5 * (inv_s[1:] + inv_s[:-1])))
            )
            grids = (np.log(energies), cumulative)
            cache[key] = grids
        return grids

    # -- construction -------------------------------------------------------

    def build_tables(
        self, ecut: float, pcut: float, e_max: float, n_points: int | None = None
    ) -> CrossSectionTables:
        """Flatten host-side data into kernel-consumable array handles.

        Generic over implementations: everything flows through the abstract query
        methods above, so a source never flattens itself differently from how it
        answers the host API (that equality is what the table parity tests pin).
        The result is host NumPy; kernel backends upload and cast it. Its contents
        are frozen after construction.

        Parameters
        ----------
        ecut, pcut
            Electron and photon cutoffs in MeV the tables are built at
            (accuracy-defining, AGENTS.md section 2.8).
        e_max
            Upper grid edge in MeV; must cover the highest primary energy.
        n_points
            Grid nodes; defaults to :data:`pyradmc.data.tables.TABLE_POINTS`.
        """
        from pyradmc.data.tables import TABLE_POINTS, build_cross_section_tables

        return build_cross_section_tables(
            self,
            ecut=ecut,
            pcut=pcut,
            e_max=e_max,
            n_points=TABLE_POINTS if n_points is None else n_points,
        )

n_materials property

n_materials: int

How many registry materials this source can answer for.

Tables are flattened for material indices 0 .. n_materials - 1 (:func:~pyradmc.data.tables.build_cross_section_tables sizes its rows by this), and a query beyond it must raise rather than approximate: a source silently answering for a material it has no data for is a silent transport bias. The default — the full registry — is for material-independent sources (test instruments); a calibrated source overrides it with its real coverage (the analytic source: water only; the tabulated source: the compiled row count).

provenance property

provenance: str

Human-readable citation for the data this source answers from.

Carried into every result's :class:~pyradmc.backends.results.RunProvenance so an archived dose says which cross-sections produced it — the single most consequential thing about a run and the one least recoverable from the dose array. The default names the class, which is honest but uninformative; a source built from compiled data overrides it with that data's own provenance string.

mu_over_rho abstractmethod

mu_over_rho(energy: float, material: int, process: int) -> float

Mass attenuation coefficient for one process, in cm^2/g.

Parameters:

Name Type Description Default
energy float

Photon energy in MeV.

required
material int

Material index into the material table.

required
process int

One of the :class:PhotonProcess constants.

required
Source code in pyradmc/data/interface.py
@abstractmethod
def mu_over_rho(self, energy: float, material: int, process: int) -> float:
    """Mass attenuation coefficient for one process, in cm^2/g.

    Parameters
    ----------
    energy
        Photon energy in MeV.
    material
        Material index into the material table.
    process
        One of the :class:`PhotonProcess` constants.
    """

mu_over_rho_total abstractmethod

mu_over_rho_total(energy: float, material: int) -> float

Total mass attenuation coefficient, in cm^2/g.

Must equal the sum over enabled processes. This redundancy is deliberate: it is a contract test (tests/unit/test_xs_contract.py).

Source code in pyradmc/data/interface.py
@abstractmethod
def mu_over_rho_total(self, energy: float, material: int) -> float:
    """Total mass attenuation coefficient, in cm^2/g.

    Must equal the sum over enabled processes. This redundancy is deliberate:
    it is a contract test (``tests/unit/test_xs_contract.py``).
    """

majorant abstractmethod

majorant(energy: float) -> float

Woodcock majorant: the maximum macroscopic total cross-section, in 1/cm.

Taken over all materials and densities present in the geometry, at the given energy. Delta (fictitious) scattering makes up the difference. The majorant must never be exceeded by any real macroscopic cross-section in the geometry; this is a contract test, and violating it silently biases the transport.

See Woodcock et al. (1965), ANL-7050.

Source code in pyradmc/data/interface.py
@abstractmethod
def majorant(self, energy: float) -> float:
    """Woodcock majorant: the maximum macroscopic total cross-section, in 1/cm.

    Taken over all materials and densities present in the geometry, at the given
    energy. Delta (fictitious) scattering makes up the difference. The majorant
    must never be exceeded by any real macroscopic cross-section in the geometry;
    this is a contract test, and violating it silently biases the transport.

    See Woodcock et al. (1965), ANL-7050.
    """

sample_coherent_cos_theta

sample_coherent_cos_theta(energy: float, material: int, rng_state: object) -> float

Sample the coherent (Rayleigh) polar scattering cosine.

The default is the Thomson distribution (zero-momentum-transfer, flat form factor); a source with real atomic form-factor data (the tabulated backend) overrides this to sample the forward-peaked coherent distribution. Kept on the source, not as a free function, because the angular shape is cross-section data (AGENTS.md 2.6): the sampling math lives in :mod:pyradmc.physics.rayleigh, the data that selects it lives here.

Source code in pyradmc/data/interface.py
def sample_coherent_cos_theta(self, energy: float, material: int, rng_state: object) -> float:
    """Sample the coherent (Rayleigh) polar scattering cosine.

    The default is the Thomson distribution (zero-momentum-transfer, flat form
    factor); a source with real atomic form-factor data (the tabulated backend)
    overrides this to sample the forward-peaked coherent distribution. Kept on the
    source, not as a free function, because the angular shape *is* cross-section data
    (AGENTS.md 2.6): the sampling math lives in :mod:`pyradmc.physics.rayleigh`, the
    data that selects it lives here.
    """
    from pyradmc.physics.rayleigh import sample_rayleigh_cos_theta

    return sample_rayleigh_cos_theta(rng_state)

coherent_cumulative

coherent_cumulative(x_grid: NDArray[float64], material: int) -> npt.NDArray[np.float64]

Return the coherent form-factor cumulative A(x)=\int_0^x F^2 x' dx' on x_grid.

This is the flattened, kernel-consumable face of :meth:sample_coherent_cos_theta: :func:~pyradmc.data.tables.build_cross_section_tables calls it per material to fill the table the Warp kernel inverts. The default is the flat form factor, A(x) = x^2/2, which the sampler inverts to the Thomson distribution — so an analytic (zero-coherent) source needs no override, and a form-factor source (tabulated) resamples its compiled cumulative onto x_grid.

Source code in pyradmc/data/interface.py
def coherent_cumulative(
    self, x_grid: npt.NDArray[np.float64], material: int
) -> npt.NDArray[np.float64]:
    r"""Return the coherent form-factor cumulative ``A(x)=\int_0^x F^2 x' dx'`` on ``x_grid``.

    This is the flattened, kernel-consumable face of :meth:`sample_coherent_cos_theta`:
    :func:`~pyradmc.data.tables.build_cross_section_tables` calls it per material to
    fill the table the Warp kernel inverts. The default is the flat form factor,
    ``A(x) = x^2/2``, which the sampler inverts to the Thomson distribution — so an
    analytic (zero-coherent) source needs no override, and a form-factor source
    (tabulated) resamples its compiled cumulative onto ``x_grid``.
    """
    return 0.5 * np.asarray(x_grid, dtype=np.float64) ** 2

restricted_stopping_power abstractmethod

restricted_stopping_power(energy: float, material: int, delta_cut: float) -> float

Restricted collision stopping power, in MeV cm^2/g.

Energy losses above delta_cut are excluded, being handled explicitly as discrete Moller (or Bhabha) events in the Class II scheme.

Berger-Seltzer formulation; see ICRU Report 37 (1984).

Source code in pyradmc/data/interface.py
@abstractmethod
def restricted_stopping_power(self, energy: float, material: int, delta_cut: float) -> float:
    """Restricted collision stopping power, in MeV cm^2/g.

    Energy losses above ``delta_cut`` are excluded, being handled explicitly as
    discrete Moller (or Bhabha) events in the Class II scheme.

    Berger-Seltzer formulation; see ICRU Report 37 (1984).
    """

radiative_stopping_power abstractmethod

radiative_stopping_power(energy: float, material: int) -> float

Radiative (bremsstrahlung) stopping power, in MeV cm^2/g.

Source code in pyradmc/data/interface.py
@abstractmethod
def radiative_stopping_power(self, energy: float, material: int) -> float:
    """Radiative (bremsstrahlung) stopping power, in MeV cm^2/g."""

moller_cross_section abstractmethod

moller_cross_section(energy: float, material: int, delta_cut: float) -> float

Restricted Moller cross-section per unit mass, in cm^2/g.

Total cross-section for a discrete knock-on collision transferring more than delta_cut (MeV, kinetic) to a delta ray. Zero when energy is at or below 2 * delta_cut: by indistinguishability the delta is the lower energy outgoing electron, so it can carry at most half the kinetic energy.

Consistency contract (tested): the energy moment of the Moller differential cross-section above delta_cut equals the difference between the unrestricted and restricted collision stopping powers. Moller (1932), doi:10.1002/andp.19324060506.

Source code in pyradmc/data/interface.py
@abstractmethod
def moller_cross_section(self, energy: float, material: int, delta_cut: float) -> float:
    """Restricted Moller cross-section per unit mass, in cm^2/g.

    Total cross-section for a discrete knock-on collision transferring more than
    ``delta_cut`` (MeV, kinetic) to a delta ray. Zero when ``energy`` is at or
    below ``2 * delta_cut``: by indistinguishability the delta is the *lower*
    energy outgoing electron, so it can carry at most half the kinetic energy.

    Consistency contract (tested): the energy moment of the Moller differential
    cross-section above ``delta_cut`` equals the difference between the
    unrestricted and restricted collision stopping powers. Moller (1932),
    doi:10.1002/andp.19324060506.
    """

csda_range abstractmethod

csda_range(energy: float, material: int) -> float

Continuous-slowing-down-approximation range, in g/cm^2.

Used for range rejection. An overestimate is safe (it rejects less); an underestimate biases the dose. Implementations must document which side they err on.

Source code in pyradmc/data/interface.py
@abstractmethod
def csda_range(self, energy: float, material: int) -> float:
    """Continuous-slowing-down-approximation range, in g/cm^2.

    Used for range rejection. An overestimate is safe (it rejects less); an
    underestimate biases the dose. Implementations must document which side they
    err on.
    """

scattering_power abstractmethod

scattering_power(energy: float, material: int, delta_cut: float) -> float

Mass angular scattering power, in rad^2 cm^2/g.

Drives the multiple-elastic-scattering hinge deflection: T rho s is the small-step <theta^2> handed to the sampler. Because Goudsmit-Saunderson pins <cos theta> = exp(-s N sigma_tr) exactly, this must be the first transport moment 2 (N_A/A) sigma_el G_1 of the screened scattering law — a core-width fit such as Highland's is not the same quantity and under-scatters at high energy (the analytic source's docstring records the approximation). delta_cut partitions the electron-electron moment: transfers below it remain condensed here, while the moment of above-cutoff Moller events is excluded because those deflections are transported explicitly by the Class-II loop.

Source code in pyradmc/data/interface.py
@abstractmethod
def scattering_power(self, energy: float, material: int, delta_cut: float) -> float:
    """Mass angular scattering power, in rad^2 cm^2/g.

    Drives the multiple-elastic-scattering hinge deflection: ``T rho s`` is
    the small-step ``<theta^2>`` handed to the sampler. Because
    Goudsmit-Saunderson pins ``<cos theta> = exp(-s N sigma_tr)`` exactly,
    this must be the *first transport moment* ``2 (N_A/A) sigma_el G_1`` of
    the screened scattering law — a core-width fit such as Highland's is not the same
    quantity and under-scatters at high energy (the analytic source's
    docstring records the approximation). ``delta_cut`` partitions the
    electron-electron moment: transfers below it remain condensed here,
    while the moment of above-cutoff Moller events is excluded because those
    deflections are transported explicitly by the Class-II loop.
    """

elastic_screening

elastic_screening(energy: float, material: int) -> float

Moliere screening parameter of the elastic scattering law, dimensionless.

Where :meth:scattering_power fixes the strength of multiple scattering, this fixes the shape: it is the parameter of the screened-Rutherford single-scattering law whose Legendre moments drive the Goudsmit-Saunderson angular distribution (:mod:pyradmc.data.goudsmit_saunderson). The two are consistent by construction — the elastic cross-section is back-derived from T = 2 (N_A/M) sigma_el G_1(eta) — so the GS mean-square deflection over a short step reproduces the Fermi-Eyges T rho s exactly. That anchoring is a contract test (tests/unit/test_elastic_screening.py): it is what makes GS a refinement of the shipped hinge rather than a rescaling of it.

Concrete on the interface, computed from the material's elemental composition, so no implementation can silently omit it and the analytic and tabulated backends cannot drift apart on the angular shape. A source carrying real elastic differential data may override it.

Source code in pyradmc/data/interface.py
def elastic_screening(self, energy: float, material: int) -> float:
    r"""Moliere screening parameter of the elastic scattering law, dimensionless.

    Where :meth:`scattering_power` fixes the *strength* of multiple
    scattering, this fixes the *shape*: it is the parameter of the
    screened-Rutherford single-scattering law whose Legendre moments drive
    the Goudsmit-Saunderson angular distribution
    (:mod:`pyradmc.data.goudsmit_saunderson`). The two are consistent by
    construction — the elastic cross-section is back-derived from
    ``T = 2 (N_A/M) sigma_el G_1(eta)`` — so the GS mean-square deflection
    over a short step reproduces the Fermi-Eyges ``T rho s`` exactly. That
    anchoring is a contract test
    (``tests/unit/test_elastic_screening.py``): it is what makes GS a
    refinement of the shipped hinge rather than a rescaling of it.

    Concrete on the interface, computed from the material's elemental
    composition, so no implementation can silently omit it and the analytic
    and tabulated backends cannot drift apart on the angular shape. A source
    carrying real elastic differential data may override it.
    """
    from pyradmc.data.goudsmit_saunderson import moliere_screening
    from pyradmc.data.materials import MATERIALS

    if not 0 <= material < self.n_materials:
        raise ValueError(f"material index {material} beyond this source")
    return moliere_screening(MATERIALS[material].composition, energy)

sample_gs_cos_theta

sample_gs_cos_theta(mean_square_angle: float, energy: float, material: int, rng_state: object) -> float

Sample the Goudsmit-Saunderson multiple-scattering deflection cosine.

The exact multiple-scattering angle for the substep, in place of the small-angle Gaussian of :func:pyradmc.physics.msc.sample_hinge_cos_theta. Takes the same mean_square_angle = T rho s the Gaussian hinge takes and consumes the same single uniform, so the two are drop-in alternatives that do not shift the random stream relative to each other — which is what lets a transport comparison isolate the angular model.

Kept on the source rather than as a free function for the reason the coherent sampler is (see :meth:sample_coherent_cos_theta): the angular shape is cross-section data. The sampling math lives in :mod:pyradmc.physics.gs; the table that selects it lives here.

Tables are memoized on a log-spaced (eta, <theta^2>) grid. Binning is safe because the table is normalized in a scaled deflection: the rescaling by this call's exact <1 - cos theta> restores the first moment exactly, so the grid resolution perturbs only the shape.

Source code in pyradmc/data/interface.py
def sample_gs_cos_theta(
    self, mean_square_angle: float, energy: float, material: int, rng_state: object
) -> float:
    """Sample the Goudsmit-Saunderson multiple-scattering deflection cosine.

    The exact multiple-scattering angle for the substep, in place of the
    small-angle Gaussian of :func:`pyradmc.physics.msc.sample_hinge_cos_theta`.
    Takes the *same* ``mean_square_angle = T rho s`` the Gaussian hinge takes
    and consumes the same single uniform, so the two are drop-in
    alternatives that do not shift the random stream relative to each other —
    which is what lets a transport comparison isolate the angular model.

    Kept on the source rather than as a free function for the reason the
    coherent sampler is (see :meth:`sample_coherent_cos_theta`): the angular
    shape *is* cross-section data. The sampling math lives in
    :mod:`pyradmc.physics.gs`; the table that selects it lives here.

    Tables are memoized on a log-spaced ``(eta, <theta^2>)`` grid. Binning is
    safe because the table is normalized in a scaled deflection: the
    rescaling by this call's exact ``<1 - cos theta>`` restores the first
    moment exactly, so the grid resolution perturbs only the shape.
    """
    from pyradmc.data.goudsmit_saunderson import gs_scaled_deflection_table
    from pyradmc.physics.gs import sample_gs_cos_theta_bilinear

    if mean_square_angle <= 0.0:
        return 1.0
    eta = self.elastic_screening(energy, material)

    cache: dict[tuple[int, int], npt.NDArray[np.float64]] | None = getattr(
        self, "_gs_table_cache", None
    )
    if cache is None:
        cache = {}
        self._gs_table_cache = cache

    # Grid in log space: both parameters span decades over the transported
    # range (eta ~ 3, <theta^2> ~ 2), and the distribution varies smoothly
    # in their logarithms. The lookup interpolates between the four
    # bracketing nodes rather than snapping to the nearest, because the
    # shape is irreducibly two-dimensional and refining a nearest-bin grid
    # to the same fidelity costs quadratically more table.
    fx = math.log(eta) * _GS_BINS_PER_LOG
    fy = math.log(max(mean_square_angle, _GS_THETA2_MIN)) * _GS_BINS_PER_LOG
    ix, iy = math.floor(fx), math.floor(fy)

    def row(i: int, j: int) -> npt.NDArray[np.float64]:
        table = cache.get((i, j))
        if table is None:
            table = gs_scaled_deflection_table(
                math.exp(i / _GS_BINS_PER_LOG),
                math.exp(j / _GS_BINS_PER_LOG),
                n_u=_GS_TABLE_NODES,
            )
            cache[(i, j)] = table
        return table

    return sample_gs_cos_theta_bilinear(
        row(ix, iy),
        row(ix + 1, iy),
        row(ix, iy + 1),
        row(ix + 1, iy + 1),
        fx - ix,
        fy - iy,
        _GS_TABLE_NODES,
        mean_square_angle,
        rng_state,
    )

restricted_range

restricted_range(energy: float, material: int, delta_cut: float) -> float

Restricted-collision range down to delta_cut, in g/cm^2.

.. math::

r(E) = \int_{\Delta}^{E} \frac{dE'}{S_{col}(E', \Delta)}

with the restricted collision stopping power of :meth:restricted_stopping_power — radiative and discrete-Moller losses are booked separately by the Class II scheme, so this is exactly the mass path over which the transport loop's continuous loss takes E to the cutoff. Together with :meth:energy_after_mass_path it defines the exact-energy-loss substep (DPM; Sempau et al. 2000, doi:10.1088/0031-9155/45/8/315), replacing the first-order S(E_start) * rho * s linearization.

Concrete on the interface: implementations answer through :meth:restricted_stopping_power, so the range can never disagree with the stopping power it integrates (trapezoid on a dense log grid, cached per (material, delta_cut); node count pinned by the derivative and round-trip tests).

Source code in pyradmc/data/interface.py
def restricted_range(self, energy: float, material: int, delta_cut: float) -> float:
    r"""Restricted-collision range down to ``delta_cut``, in g/cm^2.

    .. math::

        r(E) = \int_{\Delta}^{E} \frac{dE'}{S_{col}(E', \Delta)}

    with the *restricted collision* stopping power of
    :meth:`restricted_stopping_power` — radiative and discrete-Moller losses
    are booked separately by the Class II scheme, so this is exactly the mass
    path over which the transport loop's continuous loss takes ``E`` to the
    cutoff. Together with :meth:`energy_after_mass_path` it defines the
    exact-energy-loss substep (DPM; Sempau et al. 2000,
    doi:10.1088/0031-9155/45/8/315), replacing the first-order
    ``S(E_start) * rho * s`` linearization.

    Concrete on the interface: implementations answer through
    :meth:`restricted_stopping_power`, so the range can never disagree with
    the stopping power it integrates (trapezoid on a dense log grid, cached
    per ``(material, delta_cut)``; node count pinned by the derivative and
    round-trip tests).
    """
    log_e, cumulative = self._range_grid(material, delta_cut)
    e = min(max(energy, delta_cut), _RANGE_GRID_E_MAX)
    return float(np.interp(np.log(e), log_e, cumulative))

energy_after_mass_path

energy_after_mass_path(energy: float, material: int, delta_cut: float, mass_path: float) -> float

Energy after a continuous-loss mass path, r^-1(r(E) - mass_path).

Clamped to [delta_cut, energy]: a path at or beyond the remaining range returns exactly delta_cut (the loop's range-out branch), and interpolation wiggle can never gain energy.

Source code in pyradmc/data/interface.py
def energy_after_mass_path(
    self, energy: float, material: int, delta_cut: float, mass_path: float
) -> float:
    """Energy after a continuous-loss mass path, ``r^-1(r(E) - mass_path)``.

    Clamped to ``[delta_cut, energy]``: a path at or beyond the remaining
    range returns exactly ``delta_cut`` (the loop's range-out branch), and
    interpolation wiggle can never *gain* energy.
    """
    log_e, cumulative = self._range_grid(material, delta_cut)
    remaining = self.restricted_range(energy, material, delta_cut) - mass_path
    if remaining <= 0.0:
        return delta_cut
    e_end = float(np.exp(np.interp(remaining, cumulative, log_e)))
    return min(max(e_end, delta_cut), energy)

build_tables

build_tables(ecut: float, pcut: float, e_max: float, n_points: int | None = None) -> CrossSectionTables

Flatten host-side data into kernel-consumable array handles.

Generic over implementations: everything flows through the abstract query methods above, so a source never flattens itself differently from how it answers the host API (that equality is what the table parity tests pin). The result is host NumPy; kernel backends upload and cast it. Its contents are frozen after construction.

Parameters:

Name Type Description Default
ecut float

Electron and photon cutoffs in MeV the tables are built at (accuracy-defining, AGENTS.md section 2.8).

required
pcut float

Electron and photon cutoffs in MeV the tables are built at (accuracy-defining, AGENTS.md section 2.8).

required
e_max float

Upper grid edge in MeV; must cover the highest primary energy.

required
n_points int | None

Grid nodes; defaults to :data:pyradmc.data.tables.TABLE_POINTS.

None
Source code in pyradmc/data/interface.py
def build_tables(
    self, ecut: float, pcut: float, e_max: float, n_points: int | None = None
) -> CrossSectionTables:
    """Flatten host-side data into kernel-consumable array handles.

    Generic over implementations: everything flows through the abstract query
    methods above, so a source never flattens itself differently from how it
    answers the host API (that equality is what the table parity tests pin).
    The result is host NumPy; kernel backends upload and cast it. Its contents
    are frozen after construction.

    Parameters
    ----------
    ecut, pcut
        Electron and photon cutoffs in MeV the tables are built at
        (accuracy-defining, AGENTS.md section 2.8).
    e_max
        Upper grid edge in MeV; must cover the highest primary energy.
    n_points
        Grid nodes; defaults to :data:`pyradmc.data.tables.TABLE_POINTS`.
    """
    from pyradmc.data.tables import TABLE_POINTS, build_cross_section_tables

    return build_cross_section_tables(
        self,
        ecut=ecut,
        pcut=pcut,
        e_max=e_max,
        n_points=TABLE_POINTS if n_points is None else n_points,
    )

pyradmc.data.interface.PhotonProcess

Enumeration of photon interaction channels.

Integer-valued rather than a enum.Enum so that the values can cross into Warp kernels and the reference backend unchanged.

Source code in pyradmc/data/interface.py
class PhotonProcess:
    """Enumeration of photon interaction channels.

    Integer-valued rather than a ``enum.Enum`` so that the values can cross into
    Warp kernels and the reference backend unchanged.
    """

    COMPTON = 0
    PHOTOELECTRIC = 1
    PAIR = 2
    RAYLEIGH = 3

pyradmc.data.analytic.AnalyticCrossSections

Bases: CrossSectionSource

Closed-form photon cross-sections; see the module docstring.

Parameters:

Name Type Description Default
geometry_densities tuple[tuple[int, float], ...]

The (material, maximum mass density in g/cm^3) pairs present in the geometry. The Woodcock majorant is taken over exactly these; transporting through a geometry containing a denser voxel than declared here silently biases the transport (see :meth:majorant).

((WATER, 1.0),)
Source code in pyradmc/data/analytic.py
class AnalyticCrossSections(CrossSectionSource):
    """Closed-form photon cross-sections; see the module docstring.

    Parameters
    ----------
    geometry_densities
        The ``(material, maximum mass density in g/cm^3)`` pairs present in the
        geometry. The Woodcock majorant is taken over exactly these; transporting
        through a geometry containing a denser voxel than declared here silently
        biases the transport (see :meth:`majorant`).
    """

    def __init__(self, geometry_densities: tuple[tuple[int, float], ...] = ((WATER, 1.0),)) -> None:
        for material, density in geometry_densities:
            if not 0 <= material < self.n_materials:
                raise ValueError(
                    f"material index {material} beyond the analytic source (water only)"
                )
            if density <= 0.0:
                raise ValueError(f"non-positive density {density} for material {material}")
        self._geometry_densities = geometry_densities
        self._pair_coeffs = self._fit_pair_coefficients()
        self._radiative_coeffs = berger_seltzer.radiative_fit_coefficients(
            MATERIALS[WATER].radiative_anchors
        )
        self._range_log_energies, self._range_values = berger_seltzer.csda_range_table(
            MATERIALS[WATER]
        )

    @property
    def n_materials(self) -> int:
        """Water only, permanently.

        The photoelectric anchor, pair calibration, I-value, density effect and
        radiative fit are all water-specific. The registry may grow past water;
        this source does not.
        """
        return 1

    @property
    def provenance(self) -> str:
        """Name the parameterization, so an archived dose is not mistaken for data.

        These are closed-form fits with stated few-percent accuracy in the soft
        spectrum, not measured cross-sections; a result built on them should say so
        in the same place a tabulated result cites its library.
        """
        return (
            "analytic closed-form water (Klein-Nishina without binding, power-law "
            "photoelectric, fitted pair, Berger-Seltzer ICRU-37 stopping)"
        )

    # -- photons ------------------------------------------------------------

    def mu_over_rho(self, energy: float, material: int, process: int) -> float:
        """Mass attenuation coefficient for one channel, in cm^2/g."""
        if energy <= 0.0:
            raise ValueError(f"non-positive photon energy {energy} MeV")
        if not 0 <= material < self.n_materials:
            raise ValueError(f"material index {material} beyond the analytic source (water only)")

        if process == PhotonProcess.COMPTON:
            electrons = MATERIALS[material].electrons_per_gram
            return electrons * _klein_nishina_total_cm2(energy)
        if process == PhotonProcess.PHOTOELECTRIC:
            return self._photoelectric(energy)
        if process == PhotonProcess.PAIR:
            return self._pair(energy)
        if process == PhotonProcess.RAYLEIGH:
            # No Rayleigh by default; see the module docstring.
            return 0.0
        raise ValueError(f"unknown photon process {process}")

    def mu_over_rho_total(self, energy: float, material: int) -> float:
        """Total mass attenuation coefficient: the sum over enabled channels."""
        return (
            self.mu_over_rho(energy, material, PhotonProcess.COMPTON)
            + self.mu_over_rho(energy, material, PhotonProcess.PHOTOELECTRIC)
            + self.mu_over_rho(energy, material, PhotonProcess.PAIR)
        )

    def majorant(self, energy: float) -> float:
        """Woodcock majorant over the declared geometry contents, in 1/cm.

        The maximum of ``rho * mu/rho_total`` over the ``(material, max density)``
        pairs declared at construction. Woodcock et al. (1965), ANL-7050.
        """
        return max(
            density * self.mu_over_rho_total(energy, material)
            for material, density in self._geometry_densities
        )

    # -- electrons (water-only, like the photon channels) -----------

    def restricted_stopping_power(self, energy: float, material: int, delta_cut: float) -> float:
        """Restricted collision stopping power, Berger-Seltzer form, in MeV cm^2/g.

        Delegates to :func:`pyradmc.data.berger_seltzer.restricted_collision_stopping`
        (governing equation, citations and stated approximations there) with the water
        registry entry — whose I-value and Sternheimer coefficients are the constants
        this backend evaluated inline before the multi-material work moved them.
        """
        self._check_electron_args(energy, material)
        return berger_seltzer.restricted_collision_stopping(energy, MATERIALS[material], delta_cut)

    def radiative_stopping_power(self, energy: float, material: int) -> float:
        """Radiative stopping power, in MeV cm^2/g: a log-quadratic ESTAR fit.

        Like the pair channel, this is a calibration, not a theory: an exact
        log-quadratic (:func:`pyradmc.data.berger_seltzer.radiative_stopping`)
        through the material's transcribed NIST ESTAR anchors at 1, 10 and 20 MeV
        (Berger & Seltzer's data behind ESTAR; ICRU Report 37 (1984)).
        """
        self._check_electron_args(energy, material)
        return berger_seltzer.radiative_stopping(energy, self._radiative_coeffs)

    def moller_cross_section(self, energy: float, material: int, delta_cut: float) -> float:
        """Restricted Moller cross-section per unit mass, in cm^2/g.

        Delegates to :func:`pyradmc.data.berger_seltzer.restricted_moller_cross_section`
        (closed form and citation there). Zero at or below ``2 delta_cut``.
        """
        self._check_electron_args(energy, material)
        return berger_seltzer.restricted_moller_cross_section(
            energy, MATERIALS[material], delta_cut
        )

    def csda_range(self, energy: float, material: int) -> float:
        """CSDA range in g/cm^2: the range integral of the total stopping power.

        Precomputed at construction (:func:`pyradmc.data.berger_seltzer.csda_range_table`)
        and interpolated in log energy. Errs on the *under*-estimating side for range
        rejection (the table's grid starts above zero, truncating the sub-keV tail) by
        well under 1e-4 g/cm^2 — the safe side is documented in the interface as the
        *over*-estimate, so range rejection (when it arrives) must add its own safety
        margin anyway; the truncation here is orders of magnitude below any voxel
        dimension this engine will see.
        """
        self._check_electron_args(energy, material)
        log_e = math.log(energy)
        return float(np.interp(log_e, self._range_log_energies, self._range_values))

    def scattering_power(self, energy: float, material: int, delta_cut: float) -> float:
        r"""Mass angular scattering power, in rad^2 cm^2/g.

        The Class-II first transport moment of the Moliere-screened Rutherford law,
        ``T = 2 (N_A/A) [Z^2 sigma_tr + Z(sigma_tr - sigma_tr,M^hard)]``,
        from the material's composition
        (:func:`pyradmc.data.goudsmit_saunderson.transport_moment_scattering_power`).
        That is the strength Goudsmit-Saunderson theory pins for the shape the GS
        tables are built from; the Rossi-Greisen/Highland ``(14.1/pv)^2 / X_0`` core
        width used until 2026-09 lacked its energy-growing logarithm and
        under-scattered multi-MeV electrons. Atomic-electron scattering below
        ``delta_cut`` remains condensed; the hard Moller transport moment above
        the cut is removed because the transport loop applies it explicitly.
        """
        self._check_electron_args(energy, material)
        if delta_cut <= 0.0:
            raise ValueError(f"non-positive delta_cut {delta_cut} MeV")
        return transport_moment_scattering_power(MATERIALS[material].composition, energy, delta_cut)

    def _check_electron_args(self, energy: float, material: int) -> None:
        """Shared validation for the electron accessors."""
        if energy <= 0.0:
            raise ValueError(f"non-positive electron kinetic energy {energy} MeV")
        if not 0 <= material < self.n_materials:
            raise ValueError(f"material index {material} beyond the analytic source (water only)")

    # -- internals ------------------------------------------------------------

    def _photoelectric(self, energy: float) -> float:
        """Photoelectric tau/rho for water, E^-3 anchored at 50 keV. See module docstring."""
        return _PHOTOELECTRIC_ANCHOR_WATER * (_PHOTOELECTRIC_ANCHOR_ENERGY_MEV / energy) ** 3

    def _pair(self, energy: float) -> float:
        """Pair kappa/rho for water: threshold shape times a calibrated log-polynomial."""
        if energy <= PAIR_THRESHOLD_MEV:
            return 0.0
        shape = (1.0 - PAIR_THRESHOLD_MEV / energy) ** 3
        log_e = math.log(energy / PAIR_THRESHOLD_MEV)
        c0, c1, c2 = self._pair_coeffs
        # The fit is constrained by physical anchors, but extrapolation just above
        # threshold could dip negative; a negative cross-section is never acceptable.
        return max(0.0, shape * (c0 + c1 * log_e + c2 * log_e**2))

    def _fit_pair_coefficients(self) -> tuple[float, float, float]:
        """Least-squares calibrate the pair channel against the NIST water totals.

        Solves ``kappa_anchor(E) / shape(E) = c0 + c1 ln(E/E_th) + c2 ln^2(E/E_th)``
        where ``kappa_anchor = mu_total_NIST - mu_KN - tau``. See the module docstring
        for why this includes the (excluded) coherent channel above 2 MeV.
        """
        energies = np.array([e for e, _ in _WATER_TOTAL_MU_OVER_RHO_ANCHORS])
        totals = np.array([t for _, t in _WATER_TOTAL_MU_OVER_RHO_ANCHORS])
        electrons = MATERIALS[WATER].electrons_per_gram

        kn = np.array([electrons * _klein_nishina_total_cm2(float(e)) for e in energies])
        tau = np.array([self._photoelectric(float(e)) for e in energies])
        shape = (1.0 - PAIR_THRESHOLD_MEV / energies) ** 3
        target = (totals - kn - tau) / shape

        log_e = np.log(energies / PAIR_THRESHOLD_MEV)
        design = np.stack([np.ones_like(log_e), log_e, log_e**2], axis=1)
        coeffs, *_ = np.linalg.lstsq(design, target, rcond=None)
        return float(coeffs[0]), float(coeffs[1]), float(coeffs[2])

n_materials property

n_materials: int

Water only, permanently.

The photoelectric anchor, pair calibration, I-value, density effect and radiative fit are all water-specific. The registry may grow past water; this source does not.

provenance property

provenance: str

Name the parameterization, so an archived dose is not mistaken for data.

These are closed-form fits with stated few-percent accuracy in the soft spectrum, not measured cross-sections; a result built on them should say so in the same place a tabulated result cites its library.

mu_over_rho

mu_over_rho(energy: float, material: int, process: int) -> float

Mass attenuation coefficient for one channel, in cm^2/g.

Source code in pyradmc/data/analytic.py
def mu_over_rho(self, energy: float, material: int, process: int) -> float:
    """Mass attenuation coefficient for one channel, in cm^2/g."""
    if energy <= 0.0:
        raise ValueError(f"non-positive photon energy {energy} MeV")
    if not 0 <= material < self.n_materials:
        raise ValueError(f"material index {material} beyond the analytic source (water only)")

    if process == PhotonProcess.COMPTON:
        electrons = MATERIALS[material].electrons_per_gram
        return electrons * _klein_nishina_total_cm2(energy)
    if process == PhotonProcess.PHOTOELECTRIC:
        return self._photoelectric(energy)
    if process == PhotonProcess.PAIR:
        return self._pair(energy)
    if process == PhotonProcess.RAYLEIGH:
        # No Rayleigh by default; see the module docstring.
        return 0.0
    raise ValueError(f"unknown photon process {process}")

mu_over_rho_total

mu_over_rho_total(energy: float, material: int) -> float

Total mass attenuation coefficient: the sum over enabled channels.

Source code in pyradmc/data/analytic.py
def mu_over_rho_total(self, energy: float, material: int) -> float:
    """Total mass attenuation coefficient: the sum over enabled channels."""
    return (
        self.mu_over_rho(energy, material, PhotonProcess.COMPTON)
        + self.mu_over_rho(energy, material, PhotonProcess.PHOTOELECTRIC)
        + self.mu_over_rho(energy, material, PhotonProcess.PAIR)
    )

majorant

majorant(energy: float) -> float

Woodcock majorant over the declared geometry contents, in 1/cm.

The maximum of rho * mu/rho_total over the (material, max density) pairs declared at construction. Woodcock et al. (1965), ANL-7050.

Source code in pyradmc/data/analytic.py
def majorant(self, energy: float) -> float:
    """Woodcock majorant over the declared geometry contents, in 1/cm.

    The maximum of ``rho * mu/rho_total`` over the ``(material, max density)``
    pairs declared at construction. Woodcock et al. (1965), ANL-7050.
    """
    return max(
        density * self.mu_over_rho_total(energy, material)
        for material, density in self._geometry_densities
    )

restricted_stopping_power

restricted_stopping_power(energy: float, material: int, delta_cut: float) -> float

Restricted collision stopping power, Berger-Seltzer form, in MeV cm^2/g.

Delegates to :func:pyradmc.data.berger_seltzer.restricted_collision_stopping (governing equation, citations and stated approximations there) with the water registry entry — whose I-value and Sternheimer coefficients are the constants this backend evaluated inline before the multi-material work moved them.

Source code in pyradmc/data/analytic.py
def restricted_stopping_power(self, energy: float, material: int, delta_cut: float) -> float:
    """Restricted collision stopping power, Berger-Seltzer form, in MeV cm^2/g.

    Delegates to :func:`pyradmc.data.berger_seltzer.restricted_collision_stopping`
    (governing equation, citations and stated approximations there) with the water
    registry entry — whose I-value and Sternheimer coefficients are the constants
    this backend evaluated inline before the multi-material work moved them.
    """
    self._check_electron_args(energy, material)
    return berger_seltzer.restricted_collision_stopping(energy, MATERIALS[material], delta_cut)

radiative_stopping_power

radiative_stopping_power(energy: float, material: int) -> float

Radiative stopping power, in MeV cm^2/g: a log-quadratic ESTAR fit.

Like the pair channel, this is a calibration, not a theory: an exact log-quadratic (:func:pyradmc.data.berger_seltzer.radiative_stopping) through the material's transcribed NIST ESTAR anchors at 1, 10 and 20 MeV (Berger & Seltzer's data behind ESTAR; ICRU Report 37 (1984)).

Source code in pyradmc/data/analytic.py
def radiative_stopping_power(self, energy: float, material: int) -> float:
    """Radiative stopping power, in MeV cm^2/g: a log-quadratic ESTAR fit.

    Like the pair channel, this is a calibration, not a theory: an exact
    log-quadratic (:func:`pyradmc.data.berger_seltzer.radiative_stopping`)
    through the material's transcribed NIST ESTAR anchors at 1, 10 and 20 MeV
    (Berger & Seltzer's data behind ESTAR; ICRU Report 37 (1984)).
    """
    self._check_electron_args(energy, material)
    return berger_seltzer.radiative_stopping(energy, self._radiative_coeffs)

moller_cross_section

moller_cross_section(energy: float, material: int, delta_cut: float) -> float

Restricted Moller cross-section per unit mass, in cm^2/g.

Delegates to :func:pyradmc.data.berger_seltzer.restricted_moller_cross_section (closed form and citation there). Zero at or below 2 delta_cut.

Source code in pyradmc/data/analytic.py
def moller_cross_section(self, energy: float, material: int, delta_cut: float) -> float:
    """Restricted Moller cross-section per unit mass, in cm^2/g.

    Delegates to :func:`pyradmc.data.berger_seltzer.restricted_moller_cross_section`
    (closed form and citation there). Zero at or below ``2 delta_cut``.
    """
    self._check_electron_args(energy, material)
    return berger_seltzer.restricted_moller_cross_section(
        energy, MATERIALS[material], delta_cut
    )

csda_range

csda_range(energy: float, material: int) -> float

CSDA range in g/cm^2: the range integral of the total stopping power.

Precomputed at construction (:func:pyradmc.data.berger_seltzer.csda_range_table) and interpolated in log energy. Errs on the under-estimating side for range rejection (the table's grid starts above zero, truncating the sub-keV tail) by well under 1e-4 g/cm^2 — the safe side is documented in the interface as the over-estimate, so range rejection (when it arrives) must add its own safety margin anyway; the truncation here is orders of magnitude below any voxel dimension this engine will see.

Source code in pyradmc/data/analytic.py
def csda_range(self, energy: float, material: int) -> float:
    """CSDA range in g/cm^2: the range integral of the total stopping power.

    Precomputed at construction (:func:`pyradmc.data.berger_seltzer.csda_range_table`)
    and interpolated in log energy. Errs on the *under*-estimating side for range
    rejection (the table's grid starts above zero, truncating the sub-keV tail) by
    well under 1e-4 g/cm^2 — the safe side is documented in the interface as the
    *over*-estimate, so range rejection (when it arrives) must add its own safety
    margin anyway; the truncation here is orders of magnitude below any voxel
    dimension this engine will see.
    """
    self._check_electron_args(energy, material)
    log_e = math.log(energy)
    return float(np.interp(log_e, self._range_log_energies, self._range_values))

scattering_power

scattering_power(energy: float, material: int, delta_cut: float) -> float

Mass angular scattering power, in rad^2 cm^2/g.

The Class-II first transport moment of the Moliere-screened Rutherford law, T = 2 (N_A/A) [Z^2 sigma_tr + Z(sigma_tr - sigma_tr,M^hard)], from the material's composition (:func:pyradmc.data.goudsmit_saunderson.transport_moment_scattering_power). That is the strength Goudsmit-Saunderson theory pins for the shape the GS tables are built from; the Rossi-Greisen/Highland (14.1/pv)^2 / X_0 core width used until 2026-09 lacked its energy-growing logarithm and under-scattered multi-MeV electrons. Atomic-electron scattering below delta_cut remains condensed; the hard Moller transport moment above the cut is removed because the transport loop applies it explicitly.

Source code in pyradmc/data/analytic.py
def scattering_power(self, energy: float, material: int, delta_cut: float) -> float:
    r"""Mass angular scattering power, in rad^2 cm^2/g.

    The Class-II first transport moment of the Moliere-screened Rutherford law,
    ``T = 2 (N_A/A) [Z^2 sigma_tr + Z(sigma_tr - sigma_tr,M^hard)]``,
    from the material's composition
    (:func:`pyradmc.data.goudsmit_saunderson.transport_moment_scattering_power`).
    That is the strength Goudsmit-Saunderson theory pins for the shape the GS
    tables are built from; the Rossi-Greisen/Highland ``(14.1/pv)^2 / X_0`` core
    width used until 2026-09 lacked its energy-growing logarithm and
    under-scattered multi-MeV electrons. Atomic-electron scattering below
    ``delta_cut`` remains condensed; the hard Moller transport moment above
    the cut is removed because the transport loop applies it explicitly.
    """
    self._check_electron_args(energy, material)
    if delta_cut <= 0.0:
        raise ValueError(f"non-positive delta_cut {delta_cut} MeV")
    return transport_moment_scattering_power(MATERIALS[material].composition, energy, delta_cut)

pyradmc.data.tabulated.source.TabulatedCrossSections

Bases: CrossSectionSource

Interpolating source over compiled :class:TabulatedData.

geometry_densities are the (material, maximum mass density) pairs present in the geometry, exactly as for the analytic source; the Woodcock majorant is taken over them.

Source code in pyradmc/data/tabulated/source.py
class TabulatedCrossSections(CrossSectionSource):
    """Interpolating source over compiled :class:`TabulatedData`.

    ``geometry_densities`` are the ``(material, maximum mass density)`` pairs present
    in the geometry, exactly as for the analytic source; the Woodcock majorant is
    taken over them.
    """

    def __init__(
        self,
        data: TabulatedData,
        geometry_densities: tuple[tuple[int, float], ...] = ((WATER, 1.0),),
    ) -> None:
        registry = tuple(m.name for m in MATERIALS)
        if data.materials != registry[: len(data.materials)]:
            raise ValueError(
                f"compiled materials {data.materials} do not match the registry {registry}"
            )
        for material, density in geometry_densities:
            if not 0 <= material < len(data.materials):
                raise ValueError(f"material index {material} not in the compiled table")
            if density <= 0.0:
                raise ValueError(f"non-positive density {density} for material {material}")

        self._data = data
        self._geometry_densities = tuple(geometry_densities)
        self._photon_energies = np.asarray(data.photon_energies, dtype=np.float64)
        self._electron_energies = np.asarray(data.electron_energies, dtype=np.float64)
        self._log_electron = np.log(self._electron_energies)
        self._p_log_min, self._p_inv_dlog, self._p_n = _grid_metadata(self._photon_energies)
        self._e_log_min, self._e_inv_dlog, self._e_n = _grid_metadata(self._electron_energies)
        # float64 views for the scalar lookups (which index float arrays).
        self._mu = {p: np.asarray(a, dtype=np.float64) for p, a in data.mu_over_rho.items()}
        self._electron = {
            name: np.asarray(getattr(data, name), dtype=np.float64) for name in _ELECTRON_TABLES
        }
        # Coherent form-factor sampling data (None -> Thomson fallback).
        self._coherent_x: np.ndarray | None = None
        self._coherent_cumulative: np.ndarray | None = None
        self._n_coherent = 0
        if data.coherent_x is not None and data.coherent_cumulative is not None:
            self._coherent_x = np.asarray(data.coherent_x, dtype=np.float64)
            self._coherent_cumulative = np.asarray(data.coherent_cumulative, dtype=np.float64)
            self._n_coherent = int(self._coherent_x.shape[0])

    @property
    def n_materials(self) -> int:
        """The compiled table's row count; queries beyond it raise on lookup."""
        return len(self._data.materials)

    @property
    def provenance(self) -> str:
        """The compiled table's own citation: libraries, stopping strategy, cuts."""
        return self._data.provenance

    # -- photons ------------------------------------------------------------

    def sample_coherent_cos_theta(self, energy: float, material: int, rng_state: object) -> float:
        """Sample the coherent cosine from the compiled atomic form factor.

        Falls back to the base Thomson sampler if the table carries no form-factor data
        (``coherent_x``/``coherent_cumulative`` unset).
        """
        if self._coherent_cumulative is None or self._coherent_x is None:
            return super().sample_coherent_cos_theta(energy, material, rng_state)
        return sample_coherent_cos_theta_form_factor(
            self._coherent_cumulative[material],
            self._coherent_x,
            self._n_coherent,
            energy,
            rng_state,
        )

    def coherent_cumulative(self, x_grid: np.ndarray, material: int) -> np.ndarray:
        """Resample the compiled coherent cumulative onto ``x_grid`` for the kernel tables.

        Falls back to the base flat (Thomson) cumulative if no form-factor data. The
        cumulative is a monotone function of ``x``, so linear resampling onto a grid
        within its range is faithful; the sampler only uses cumulative ratios.
        """
        if self._coherent_cumulative is None or self._coherent_x is None:
            return super().coherent_cumulative(x_grid, material)
        resampled = np.interp(
            np.asarray(x_grid, dtype=np.float64),
            self._coherent_x,
            self._coherent_cumulative[material],
        )
        return np.asarray(resampled, dtype=np.float64)

    def mu_over_rho(self, energy: float, material: int, process: int) -> float:
        """Mass attenuation coefficient for one channel, in cm^2/g."""
        if energy <= 0.0:
            raise ValueError(f"non-positive photon energy {energy} MeV")
        table = self._mu.get(process)
        if table is None:
            return 0.0  # a channel absent from the compiled data is disabled
        return lookup_loglinear_2d(
            table, material, self._p_log_min, self._p_inv_dlog, self._p_n, energy
        )

    def mu_over_rho_total(self, energy: float, material: int) -> float:
        """Total mass attenuation coefficient: the sum over compiled channels."""
        return sum(self.mu_over_rho(energy, material, p) for p in self._mu)

    def majorant(self, energy: float) -> float:
        """Woodcock majorant over the declared geometry contents, in 1/cm."""
        return max(
            density * self.mu_over_rho_total(energy, material)
            for material, density in self._geometry_densities
        )

    # -- electrons ----------------------------------------------------------

    def restricted_stopping_power(self, energy: float, material: int, delta_cut: float) -> float:
        """Restricted collision stopping power at the compiled cut, in MeV cm^2/g."""
        self._check_delta_cut(delta_cut)
        return self._electron_lookup("restricted_stopping", energy, material)

    def radiative_stopping_power(self, energy: float, material: int) -> float:
        """Radiative (bremsstrahlung) mass stopping power, in MeV cm^2/g."""
        return self._electron_lookup("radiative_stopping", energy, material)

    def moller_cross_section(self, energy: float, material: int, delta_cut: float) -> float:
        """Moller cross section for delta rays above the compiled cut, in cm^2/g."""
        self._check_delta_cut(delta_cut)
        return self._electron_lookup("moller", energy, material)

    def csda_range(self, energy: float, material: int) -> float:
        """Continuous-slowing-down range, in g/cm^2."""
        return self._electron_lookup("csda_range", energy, material)

    def scattering_power(self, energy: float, material: int, delta_cut: float) -> float:
        """Class-II multiple-scattering power at the compiled cut, rad^2 cm^2/g."""
        self._check_delta_cut(delta_cut)
        return self._electron_lookup("scattering_power", energy, material)

    # -- sub-grid product integration (AGENTS.md 2.7) -----------------------

    def integrate_product(
        self,
        quantity_a: str,
        quantity_b: str,
        material: int,
        e_lo: float,
        e_hi: float,
        *,
        n_sub: int = 64,
    ) -> float:
        r"""Integrate the product of two tabulated electron quantities over energy.

        Returns ``\int_{e_lo}^{e_hi} a(E) b(E) dE`` where ``a`` and ``b`` are the
        log-linear interpolants of the named quantities — capturing their intra-bin
        covariance, which multiplying separately averaged bin quantities discards
        (AGENTS.md 2.7). The interval is sampled on a fine geometric sub-grid and
        the product integrated by the trapezoidal rule; ``n_sub`` sets the resolution.
        """
        if e_hi <= e_lo:
            raise ValueError(f"empty interval [{e_lo}, {e_hi}]")
        energies = np.geomspace(e_lo, e_hi, n_sub + 1)
        a = self._interp_electron(quantity_a, material, energies)
        b = self._interp_electron(quantity_b, material, energies)
        return float(np.trapezoid(a * b, energies))

    # -- internals ----------------------------------------------------------

    def _check_delta_cut(self, delta_cut: float) -> None:
        if not math.isclose(delta_cut, self._data.delta_cut, rel_tol=1e-9, abs_tol=0.0):
            raise ValueError(
                f"tables were compiled at delta_cut={self._data.delta_cut} MeV but the "
                f"transport requested {delta_cut} MeV; recompile at the run's ECUT"
            )

    def _electron_lookup(self, name: str, energy: float, material: int) -> float:
        if energy <= 0.0:
            raise ValueError(f"non-positive electron energy {energy} MeV")
        return lookup_loglinear_2d(
            self._electron[name], material, self._e_log_min, self._e_inv_dlog, self._e_n, energy
        )

    def _interp_electron(self, name: str, material: int, energies: np.ndarray) -> np.ndarray:
        if name not in self._electron:
            raise ValueError(f"unknown electron quantity {name!r}")
        interp = np.interp(np.log(energies), self._log_electron, self._electron[name][material])
        return np.asarray(interp, dtype=np.float64)

n_materials property

n_materials: int

The compiled table's row count; queries beyond it raise on lookup.

provenance property

provenance: str

The compiled table's own citation: libraries, stopping strategy, cuts.

sample_coherent_cos_theta

sample_coherent_cos_theta(energy: float, material: int, rng_state: object) -> float

Sample the coherent cosine from the compiled atomic form factor.

Falls back to the base Thomson sampler if the table carries no form-factor data (coherent_x/coherent_cumulative unset).

Source code in pyradmc/data/tabulated/source.py
def sample_coherent_cos_theta(self, energy: float, material: int, rng_state: object) -> float:
    """Sample the coherent cosine from the compiled atomic form factor.

    Falls back to the base Thomson sampler if the table carries no form-factor data
    (``coherent_x``/``coherent_cumulative`` unset).
    """
    if self._coherent_cumulative is None or self._coherent_x is None:
        return super().sample_coherent_cos_theta(energy, material, rng_state)
    return sample_coherent_cos_theta_form_factor(
        self._coherent_cumulative[material],
        self._coherent_x,
        self._n_coherent,
        energy,
        rng_state,
    )

coherent_cumulative

coherent_cumulative(x_grid: ndarray, material: int) -> np.ndarray

Resample the compiled coherent cumulative onto x_grid for the kernel tables.

Falls back to the base flat (Thomson) cumulative if no form-factor data. The cumulative is a monotone function of x, so linear resampling onto a grid within its range is faithful; the sampler only uses cumulative ratios.

Source code in pyradmc/data/tabulated/source.py
def coherent_cumulative(self, x_grid: np.ndarray, material: int) -> np.ndarray:
    """Resample the compiled coherent cumulative onto ``x_grid`` for the kernel tables.

    Falls back to the base flat (Thomson) cumulative if no form-factor data. The
    cumulative is a monotone function of ``x``, so linear resampling onto a grid
    within its range is faithful; the sampler only uses cumulative ratios.
    """
    if self._coherent_cumulative is None or self._coherent_x is None:
        return super().coherent_cumulative(x_grid, material)
    resampled = np.interp(
        np.asarray(x_grid, dtype=np.float64),
        self._coherent_x,
        self._coherent_cumulative[material],
    )
    return np.asarray(resampled, dtype=np.float64)

mu_over_rho

mu_over_rho(energy: float, material: int, process: int) -> float

Mass attenuation coefficient for one channel, in cm^2/g.

Source code in pyradmc/data/tabulated/source.py
def mu_over_rho(self, energy: float, material: int, process: int) -> float:
    """Mass attenuation coefficient for one channel, in cm^2/g."""
    if energy <= 0.0:
        raise ValueError(f"non-positive photon energy {energy} MeV")
    table = self._mu.get(process)
    if table is None:
        return 0.0  # a channel absent from the compiled data is disabled
    return lookup_loglinear_2d(
        table, material, self._p_log_min, self._p_inv_dlog, self._p_n, energy
    )

mu_over_rho_total

mu_over_rho_total(energy: float, material: int) -> float

Total mass attenuation coefficient: the sum over compiled channels.

Source code in pyradmc/data/tabulated/source.py
def mu_over_rho_total(self, energy: float, material: int) -> float:
    """Total mass attenuation coefficient: the sum over compiled channels."""
    return sum(self.mu_over_rho(energy, material, p) for p in self._mu)

majorant

majorant(energy: float) -> float

Woodcock majorant over the declared geometry contents, in 1/cm.

Source code in pyradmc/data/tabulated/source.py
def majorant(self, energy: float) -> float:
    """Woodcock majorant over the declared geometry contents, in 1/cm."""
    return max(
        density * self.mu_over_rho_total(energy, material)
        for material, density in self._geometry_densities
    )

restricted_stopping_power

restricted_stopping_power(energy: float, material: int, delta_cut: float) -> float

Restricted collision stopping power at the compiled cut, in MeV cm^2/g.

Source code in pyradmc/data/tabulated/source.py
def restricted_stopping_power(self, energy: float, material: int, delta_cut: float) -> float:
    """Restricted collision stopping power at the compiled cut, in MeV cm^2/g."""
    self._check_delta_cut(delta_cut)
    return self._electron_lookup("restricted_stopping", energy, material)

radiative_stopping_power

radiative_stopping_power(energy: float, material: int) -> float

Radiative (bremsstrahlung) mass stopping power, in MeV cm^2/g.

Source code in pyradmc/data/tabulated/source.py
def radiative_stopping_power(self, energy: float, material: int) -> float:
    """Radiative (bremsstrahlung) mass stopping power, in MeV cm^2/g."""
    return self._electron_lookup("radiative_stopping", energy, material)

moller_cross_section

moller_cross_section(energy: float, material: int, delta_cut: float) -> float

Moller cross section for delta rays above the compiled cut, in cm^2/g.

Source code in pyradmc/data/tabulated/source.py
def moller_cross_section(self, energy: float, material: int, delta_cut: float) -> float:
    """Moller cross section for delta rays above the compiled cut, in cm^2/g."""
    self._check_delta_cut(delta_cut)
    return self._electron_lookup("moller", energy, material)

csda_range

csda_range(energy: float, material: int) -> float

Continuous-slowing-down range, in g/cm^2.

Source code in pyradmc/data/tabulated/source.py
def csda_range(self, energy: float, material: int) -> float:
    """Continuous-slowing-down range, in g/cm^2."""
    return self._electron_lookup("csda_range", energy, material)

scattering_power

scattering_power(energy: float, material: int, delta_cut: float) -> float

Class-II multiple-scattering power at the compiled cut, rad^2 cm^2/g.

Source code in pyradmc/data/tabulated/source.py
def scattering_power(self, energy: float, material: int, delta_cut: float) -> float:
    """Class-II multiple-scattering power at the compiled cut, rad^2 cm^2/g."""
    self._check_delta_cut(delta_cut)
    return self._electron_lookup("scattering_power", energy, material)

integrate_product

integrate_product(quantity_a: str, quantity_b: str, material: int, e_lo: float, e_hi: float, *, n_sub: int = 64) -> float

Integrate the product of two tabulated electron quantities over energy.

Returns \int_{e_lo}^{e_hi} a(E) b(E) dE where a and b are the log-linear interpolants of the named quantities — capturing their intra-bin covariance, which multiplying separately averaged bin quantities discards (AGENTS.md 2.7). The interval is sampled on a fine geometric sub-grid and the product integrated by the trapezoidal rule; n_sub sets the resolution.

Source code in pyradmc/data/tabulated/source.py
def integrate_product(
    self,
    quantity_a: str,
    quantity_b: str,
    material: int,
    e_lo: float,
    e_hi: float,
    *,
    n_sub: int = 64,
) -> float:
    r"""Integrate the product of two tabulated electron quantities over energy.

    Returns ``\int_{e_lo}^{e_hi} a(E) b(E) dE`` where ``a`` and ``b`` are the
    log-linear interpolants of the named quantities — capturing their intra-bin
    covariance, which multiplying separately averaged bin quantities discards
    (AGENTS.md 2.7). The interval is sampled on a fine geometric sub-grid and
    the product integrated by the trapezoidal rule; ``n_sub`` sets the resolution.
    """
    if e_hi <= e_lo:
        raise ValueError(f"empty interval [{e_lo}, {e_hi}]")
    energies = np.geomspace(e_lo, e_hi, n_sub + 1)
    a = self._interp_electron(quantity_a, material, energies)
    b = self._interp_electron(quantity_b, material, energies)
    return float(np.trapezoid(a * b, energies))

pyradmc.data.materials.MaterialData dataclass

Host-side physical data for one material.

Attributes:

Name Type Description
name str

Human-readable identifier.

density float

Reference mass density in g/cm^3. Voxel densities in the geometry scale macroscopic cross-sections relative to this via the mass quantities, so this value is informational for water-like transport, not accuracy-defining. (The Sternheimer coefficients below were computed at this density; evaluating them for a voxel at a different density is the standard density-scaling approximation every mass-quantity lookup in this engine already makes.)

electrons_per_gram float

Electron density per unit mass in 1/g, i.e. N_A * <Z/A>. Multiplies per-electron cross-sections (Klein-Nishina, Moller, Berger-Seltzer) into mass quantities. Stored explicitly rather than derived so that water can keep its historical molecular-weight value (10 N_A / 18.01528) bit-exactly.

composition tuple[tuple[int, float], ...]

Elemental mass fractions ((Z, w), ...), ascending in Z, summing to one within the rounding of the published table they were transcribed from. This is the compile-time mixing key for per-element photon/electron data.

mean_excitation_mev float

Mean excitation energy I in MeV (ICRU-37 vintage, consistent with the Sternheimer coefficients and with NIST ESTAR).

sternheimer SternheimerParameters

Density-effect coefficients, computed at density; see :class:SternheimerParameters.

radiative_anchors tuple[tuple[float, float], ...]

((E_MeV, S_rad_MeV_cm2_g), ...) NIST ESTAR radiative stopping anchors the log-quadratic radiative fit passes through exactly (three or more, ascending).

Source code in pyradmc/data/materials.py
@dataclass(frozen=True)
class MaterialData:
    """Host-side physical data for one material.

    Attributes
    ----------
    name
        Human-readable identifier.
    density
        Reference mass density in g/cm^3. Voxel densities in the geometry scale
        macroscopic cross-sections relative to this via the mass quantities, so this
        value is informational for water-like transport, not accuracy-defining.
        (The Sternheimer coefficients below *were computed at* this density; evaluating
        them for a voxel at a different density is the standard density-scaling
        approximation every mass-quantity lookup in this engine already makes.)
    electrons_per_gram
        Electron density per unit mass in 1/g, i.e. ``N_A * <Z/A>``. Multiplies
        per-electron cross-sections (Klein-Nishina, Moller, Berger-Seltzer) into mass
        quantities. Stored explicitly rather than derived so that water can keep its
        historical molecular-weight value (10 N_A / 18.01528) bit-exactly.
    composition
        Elemental mass fractions ``((Z, w), ...)``, ascending in Z, summing to one
        within the rounding of the published table they were transcribed from. This is
        the compile-time mixing key for per-element photon/electron data.
    mean_excitation_mev
        Mean excitation energy I in MeV (ICRU-37 vintage, consistent with the
        Sternheimer coefficients and with NIST ESTAR).
    sternheimer
        Density-effect coefficients, computed at ``density``; see
        :class:`SternheimerParameters`.
    radiative_anchors
        ``((E_MeV, S_rad_MeV_cm2_g), ...)`` NIST ESTAR radiative stopping anchors the
        log-quadratic radiative fit passes through exactly (three or more, ascending).
    """

    name: str
    density: float
    electrons_per_gram: float
    composition: tuple[tuple[int, float], ...]
    mean_excitation_mev: float
    sternheimer: SternheimerParameters
    radiative_anchors: tuple[tuple[float, float], ...]

Sources

pyradmc.geometry.source.Source

Bases: ABC

Interface for an open-field primary source (consumed by Engine.run).

Implement :meth:emit and :attr:max_energy and the reference backend transports it. For a device backend, the default :meth:sample_batch provides the simple host-pre-sampling route for free; override :attr:warp_sampler with a @wp.func for the advanced in-kernel route. See the module docstring.

Source code in pyradmc/geometry/source.py
class Source(ABC):
    """Interface for an open-field primary source (consumed by ``Engine.run``).

    Implement :meth:`emit` and :attr:`max_energy` and the reference backend transports
    it. For a device backend, the default :meth:`sample_batch` provides the *simple*
    host-pre-sampling route for free; override :attr:`warp_sampler` with a ``@wp.func``
    for the *advanced* in-kernel route. See the module docstring.
    """

    #: Optional ``@wp.func`` for the advanced Warp route; ``None`` selects pre-sampling.
    #: Signature ``(history_index: int, state) -> (kind, energy, x, y, z, ux, uy, uz,
    #: weight)``. Left untyped because it is a Warp object the core never imports.
    warp_sampler: ClassVar[Any] = None

    @property
    @abstractmethod
    def max_energy(self) -> float:
        """Highest primary energy in MeV, for cross-section table sizing."""

    @abstractmethod
    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one primary, drawing from the per-history RNG ``state``."""

    def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
        """Host-sample histories ``[history_offset, history_offset + n)`` into columns.

        Returns the device upload columns (``particle_type`` plus
        :data:`_UPLOAD_COLUMNS`). The default calls :meth:`emit` per history; override
        for a vectorized sampler.
        """
        return _presample(self.emit, seed, history_offset, n)

max_energy abstractmethod property

max_energy: float

Highest primary energy in MeV, for cross-section table sizing.

emit abstractmethod

emit(rng_state: RNGState) -> Primary

Emit one primary, drawing from the per-history RNG state.

Source code in pyradmc/geometry/source.py
@abstractmethod
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one primary, drawing from the per-history RNG ``state``."""

sample_batch

sample_batch(seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]

Host-sample histories [history_offset, history_offset + n) into columns.

Returns the device upload columns (particle_type plus :data:_UPLOAD_COLUMNS). The default calls :meth:emit per history; override for a vectorized sampler.

Source code in pyradmc/geometry/source.py
def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
    """Host-sample histories ``[history_offset, history_offset + n)`` into columns.

    Returns the device upload columns (``particle_type`` plus
    :data:`_UPLOAD_COLUMNS`). The default calls :meth:`emit` per history; override
    for a vectorized sampler.
    """
    return _presample(self.emit, seed, history_offset, n)

pyradmc.geometry.source.BeamletSource

Bases: ABC

Interface for a beamlet-resolved source (consumed by Engine.run_dij).

Like :class:Source but every emission is tagged by a beamlet index: the Dij assembles one dose column per beamlet. Implement :meth:emit, :meth:n_beamlets and :attr:max_energy; :meth:sample_beamlet_batch is the default simple route and :attr:warp_beamlet_sampler the advanced one. (Beamlet geometry — a rectangle, a lattice, an arbitrary aperture — is the source's private business; the engine only ever asks for the count and per-beamlet emissions.)

Source code in pyradmc/geometry/source.py
class BeamletSource(ABC):
    """Interface for a beamlet-resolved source (consumed by ``Engine.run_dij``).

    Like :class:`Source` but every emission is tagged by a beamlet index: the Dij
    assembles one dose column per beamlet. Implement :meth:`emit`, :meth:`n_beamlets`
    and :attr:`max_energy`; :meth:`sample_beamlet_batch` is the default simple route
    and :attr:`warp_beamlet_sampler` the advanced one. (Beamlet *geometry* — a
    rectangle, a lattice, an arbitrary aperture — is the source's private business; the
    engine only ever asks for the count and per-beamlet emissions.)
    """

    #: Optional ``@wp.func`` for the advanced Warp Dij route; ``None`` selects
    #: pre-sampling. Signature ``(beamlet: int, within_index: int, state) ->
    #: (energy, x, y, z, ux, uy, uz, weight)`` — a photon with a statistical
    #: weight (1.0 for analog sources; the collimated wrappers attenuate by it),
    #: mirroring what :meth:`emit` returns for the same stream.
    warp_beamlet_sampler: ClassVar[Any] = None

    @property
    @abstractmethod
    def max_energy(self) -> float:
        """Highest primary energy in MeV, for cross-section table sizing."""

    @property
    @abstractmethod
    def n_beamlets(self) -> int:
        """Number of beamlets whose columns the Dij will hold."""

    @abstractmethod
    def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
        """Emit one primary for ``beamlet``, drawing from the per-history RNG ``state``."""

    def sample_beamlet_batch(
        self, seed: int, history_offset: int, n: int, beamlet: int
    ) -> dict[str, npt.NDArray[Any]]:
        """Host-sample ``n`` primaries of one ``beamlet`` into device upload columns.

        The caller (the Dij engine) chooses ``seed``/``history_offset`` to realize the
        correlated-sampling history mapping; this just emits that beamlet's primaries.
        The default calls :meth:`emit`; override for a vectorized sampler.
        """
        return _presample(lambda state: self.emit(beamlet, state), seed, history_offset, n)

max_energy abstractmethod property

max_energy: float

Highest primary energy in MeV, for cross-section table sizing.

n_beamlets abstractmethod property

n_beamlets: int

Number of beamlets whose columns the Dij will hold.

emit abstractmethod

emit(beamlet: int, rng_state: RNGState) -> Primary

Emit one primary for beamlet, drawing from the per-history RNG state.

Source code in pyradmc/geometry/source.py
@abstractmethod
def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
    """Emit one primary for ``beamlet``, drawing from the per-history RNG ``state``."""

sample_beamlet_batch

sample_beamlet_batch(seed: int, history_offset: int, n: int, beamlet: int) -> dict[str, npt.NDArray[Any]]

Host-sample n primaries of one beamlet into device upload columns.

The caller (the Dij engine) chooses seed/history_offset to realize the correlated-sampling history mapping; this just emits that beamlet's primaries. The default calls :meth:emit; override for a vectorized sampler.

Source code in pyradmc/geometry/source.py
def sample_beamlet_batch(
    self, seed: int, history_offset: int, n: int, beamlet: int
) -> dict[str, npt.NDArray[Any]]:
    """Host-sample ``n`` primaries of one ``beamlet`` into device upload columns.

    The caller (the Dij engine) chooses ``seed``/``history_offset`` to realize the
    correlated-sampling history mapping; this just emits that beamlet's primaries.
    The default calls :meth:`emit`; override for a vectorized sampler.
    """
    return _presample(lambda state: self.emit(beamlet, state), seed, history_offset, n)

pyradmc.geometry.source.Primary

Bases: NamedTuple

One emitted primary particle.

kind and weight are additive with defaults so the monoenergetic beam sources — which emit unit-weight photons and construct Primary positionally with the first seven fields — are unchanged. kind is None for those sources, meaning "defer to the engine's primary_kind argument"; a phase-space source sets it per record ("photon", "electron", "positron"). A weight other than 1.0 is the statistical weight a phase-space record carries.

Source code in pyradmc/geometry/source.py
class Primary(NamedTuple):
    """One emitted primary particle.

    ``kind`` and ``weight`` are additive with defaults so the monoenergetic beam
    sources — which emit unit-weight photons and construct ``Primary`` positionally
    with the first seven fields — are unchanged. ``kind`` is ``None`` for those
    sources, meaning "defer to the engine's ``primary_kind`` argument"; a
    phase-space source sets it per record ("photon", "electron", "positron"). A
    ``weight`` other than 1.0 is the statistical weight a phase-space record carries.
    """

    energy: float
    x: float
    y: float
    z: float
    ux: float
    uy: float
    uz: float
    kind: str | None = None
    weight: float = 1.0

pyradmc.geometry.source.PencilBeamSource dataclass

Bases: Source

Zero-width monoenergetic beam from a fixed point along a fixed direction.

The direction is normalized at construction; a non-unit direction here would silently stretch every sampled path length.

Source code in pyradmc/geometry/source.py
@dataclass(frozen=True)
class PencilBeamSource(Source):
    """Zero-width monoenergetic beam from a fixed point along a fixed direction.

    The direction is normalized at construction; a non-unit direction here would
    silently stretch every sampled path length.
    """

    energy: float
    position: tuple[float, float, float]
    direction: tuple[float, float, float]

    def __post_init__(self) -> None:
        """Validate energy and normalize the direction."""
        if self.energy <= 0.0:
            raise ValueError(f"non-positive energy {self.energy} MeV")
        norm = math.sqrt(sum(c * c for c in self.direction))
        if norm == 0.0:
            raise ValueError("zero direction vector")
        object.__setattr__(self, "direction", tuple(c / norm for c in self.direction))

    @property
    def max_energy(self) -> float:
        """The single beam energy."""
        return self.energy

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit the (deterministic) primary; consumes no random numbers."""
        return Primary(
            self.energy,
            self.position[0],
            self.position[1],
            self.position[2],
            self.direction[0],
            self.direction[1],
            self.direction[2],
        )

max_energy property

max_energy: float

The single beam energy.

emit

emit(rng_state: RNGState) -> Primary

Emit the (deterministic) primary; consumes no random numbers.

Source code in pyradmc/geometry/source.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit the (deterministic) primary; consumes no random numbers."""
    return Primary(
        self.energy,
        self.position[0],
        self.position[1],
        self.position[2],
        self.direction[0],
        self.direction[1],
        self.direction[2],
    )

pyradmc.geometry.source.ParallelBeamSource dataclass

Bases: Source

Broad parallel beam along +z, uniform over a rectangular field at plane z.

The broad-beam geometry of the buildup test: uniform fluence over x_range x y_range, all photons travelling in +z.

Source code in pyradmc/geometry/source.py
@dataclass(frozen=True)
class ParallelBeamSource(Source):
    """Broad parallel beam along +z, uniform over a rectangular field at plane z.

    The broad-beam geometry of the buildup test: uniform fluence over
    ``x_range`` x ``y_range``, all photons travelling in +z.
    """

    energy: float
    z: float
    x_range: tuple[float, float]
    y_range: tuple[float, float]

    def __post_init__(self) -> None:
        """Validate energy and field extents."""
        if self.energy <= 0.0:
            raise ValueError(f"non-positive energy {self.energy} MeV")
        if self.x_range[1] <= self.x_range[0] or self.y_range[1] <= self.y_range[0]:
            raise ValueError("empty field")

    @property
    def max_energy(self) -> float:
        """The single beam energy."""
        return self.energy

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one primary at a uniform position in the field; consumes two uniforms."""
        x = self.x_range[0] + (self.x_range[1] - self.x_range[0]) * uniform(rng_state)
        y = self.y_range[0] + (self.y_range[1] - self.y_range[0]) * uniform(rng_state)
        return Primary(self.energy, x, y, self.z, 0.0, 0.0, 1.0)

max_energy property

max_energy: float

The single beam energy.

emit

emit(rng_state: RNGState) -> Primary

Emit one primary at a uniform position in the field; consumes two uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one primary at a uniform position in the field; consumes two uniforms."""
    x = self.x_range[0] + (self.x_range[1] - self.x_range[0]) * uniform(rng_state)
    y = self.y_range[0] + (self.y_range[1] - self.y_range[0]) * uniform(rng_state)
    return Primary(self.energy, x, y, self.z, 0.0, 0.0, 1.0)

pyradmc.geometry.source.BeamletGridSource dataclass

Bases: BeamletSource

Parallel beamlet lattice along +z: an n_x x n_y tiling of the field.

The Dij source. Each beamlet is one rectangle of the tiling, indexed x-major: j = jx * n_y + jy. Which beamlet a history feeds is the caller's decision — the engines derive it deterministically from the history index (stratified sampling), so per-beamlet history counts are exact rather than multinomial. emit then places the primary uniformly within that beamlet, consuming exactly the two uniforms :class:ParallelBeamSource consumes for the whole field; a 1x1 lattice is therefore bit-identical to the open field on a given target (test-pinned).

Beamlets partition the primary fluence and transport is linear in the source, so scoring each history's whole family into its beamlet's column decomposes the open-field dose exactly — no crosstalk approximation. (A phase-space source would break unique beamlet ownership; that is a known limitation.)

Source code in pyradmc/geometry/source.py
@dataclass(frozen=True)
class BeamletGridSource(BeamletSource):
    """Parallel beamlet lattice along +z: an ``n_x`` x ``n_y`` tiling of the field.

    The Dij source. Each beamlet is one rectangle of the tiling, indexed
    x-major: ``j = jx * n_y + jy``. Which beamlet a history feeds is the *caller's*
    decision — the engines derive it deterministically from the history index
    (stratified sampling), so per-beamlet history counts are exact rather than
    multinomial. ``emit`` then places the primary uniformly *within* that beamlet,
    consuming exactly the two uniforms :class:`ParallelBeamSource` consumes for the
    whole field; a 1x1 lattice is therefore bit-identical to the open field on a
    given target (test-pinned).

    Beamlets partition the primary fluence and transport is linear in the source,
    so scoring each history's whole family into its beamlet's column decomposes
    the open-field dose exactly — no crosstalk approximation. (A phase-space
    source would break unique beamlet ownership; that is a known limitation.)
    """

    energy: float
    z: float
    x_range: tuple[float, float]
    y_range: tuple[float, float]
    n_x: int
    n_y: int

    def __post_init__(self) -> None:
        """Validate energy, field extents, and lattice shape."""
        if self.energy <= 0.0:
            raise ValueError(f"non-positive energy {self.energy} MeV")
        if self.x_range[1] <= self.x_range[0] or self.y_range[1] <= self.y_range[0]:
            raise ValueError("empty field")
        if self.n_x < 1 or self.n_y < 1:
            raise ValueError(f"lattice must be at least 1x1, got {self.n_x}x{self.n_y}")

    @property
    def max_energy(self) -> float:
        """The single beam energy."""
        return self.energy

    @property
    def n_beamlets(self) -> int:
        """Number of beamlets in the lattice."""
        return self.n_x * self.n_y

    def beamlet_bounds(self, beamlet: int) -> tuple[float, float, float, float]:
        """Rectangle ``(x_lo, x_hi, y_lo, y_hi)`` of one beamlet.

        Edges are computed by linear interpolation between the field bounds (never
        by accumulating widths), so the outer edges of the lattice are exactly the
        field bounds and shared edges are exactly equal between neighbours.
        """
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside lattice of {self.n_beamlets}")
        jx, jy = divmod(beamlet, self.n_y)
        return (
            self._edge(self.x_range, jx, self.n_x),
            self._edge(self.x_range, jx + 1, self.n_x),
            self._edge(self.y_range, jy, self.n_y),
            self._edge(self.y_range, jy + 1, self.n_y),
        )

    @staticmethod
    def _edge(bounds: tuple[float, float], i: int, n: int) -> float:
        lo, hi = bounds
        if i == 0:
            return lo
        if i == n:
            return hi
        return lo + (hi - lo) * (i / n)

    def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
        """Emit one primary uniformly within ``beamlet``; consumes two uniforms.

        The draw order (x, then y) and count match :class:`ParallelBeamSource.emit`
        so the 1x1 lattice bit-equivalence holds.
        """
        x_lo, x_hi, y_lo, y_hi = self.beamlet_bounds(beamlet)
        x = x_lo + (x_hi - x_lo) * uniform(rng_state)
        y = y_lo + (y_hi - y_lo) * uniform(rng_state)
        return Primary(self.energy, x, y, self.z, 0.0, 0.0, 1.0)

max_energy property

max_energy: float

The single beam energy.

n_beamlets property

n_beamlets: int

Number of beamlets in the lattice.

beamlet_bounds

beamlet_bounds(beamlet: int) -> tuple[float, float, float, float]

Rectangle (x_lo, x_hi, y_lo, y_hi) of one beamlet.

Edges are computed by linear interpolation between the field bounds (never by accumulating widths), so the outer edges of the lattice are exactly the field bounds and shared edges are exactly equal between neighbours.

Source code in pyradmc/geometry/source.py
def beamlet_bounds(self, beamlet: int) -> tuple[float, float, float, float]:
    """Rectangle ``(x_lo, x_hi, y_lo, y_hi)`` of one beamlet.

    Edges are computed by linear interpolation between the field bounds (never
    by accumulating widths), so the outer edges of the lattice are exactly the
    field bounds and shared edges are exactly equal between neighbours.
    """
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside lattice of {self.n_beamlets}")
    jx, jy = divmod(beamlet, self.n_y)
    return (
        self._edge(self.x_range, jx, self.n_x),
        self._edge(self.x_range, jx + 1, self.n_x),
        self._edge(self.y_range, jy, self.n_y),
        self._edge(self.y_range, jy + 1, self.n_y),
    )

emit

emit(beamlet: int, rng_state: RNGState) -> Primary

Emit one primary uniformly within beamlet; consumes two uniforms.

The draw order (x, then y) and count match :class:ParallelBeamSource.emit so the 1x1 lattice bit-equivalence holds.

Source code in pyradmc/geometry/source.py
def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
    """Emit one primary uniformly within ``beamlet``; consumes two uniforms.

    The draw order (x, then y) and count match :class:`ParallelBeamSource.emit`
    so the 1x1 lattice bit-equivalence holds.
    """
    x_lo, x_hi, y_lo, y_hi = self.beamlet_bounds(beamlet)
    x = x_lo + (x_hi - x_lo) * uniform(rng_state)
    y = y_lo + (y_hi - y_lo) * uniform(rng_state)
    return Primary(self.energy, x, y, self.z, 0.0, 0.0, 1.0)

pyradmc.geometry.source.GaussianSpotBeamSource

Bases: Source

Photons born on a plane rectangle, aimed from a 2D-Gaussian focal spot.

The simplified head-input source of the BLD workstream: emission happens on the rectangle at the reference plane (e.g. directly upstream of the limiting devices), each photon travelling as if it originated at the Gaussian spot — compose with :class:~pyradmc.geometry.collimation.CollimatedSource (whose full-line convention handles the devices downstream of this plane) or feed the head pre-solve. sigma_u = sigma_v = 0 degenerates to :class:SpectralBeamSource's fan lines, started on the plane. Transports on both backends via the vectorized pre-sampling route.

Source code in pyradmc/geometry/source.py
class GaussianSpotBeamSource(Source):
    """Photons born on a plane rectangle, aimed from a 2D-Gaussian focal spot.

    The simplified head-input source of the BLD workstream: emission happens on
    the rectangle at the reference plane (e.g. directly upstream of the limiting
    devices), each photon travelling as if it originated at the Gaussian spot —
    compose with :class:`~pyradmc.geometry.collimation.CollimatedSource` (whose
    full-line convention handles the devices downstream of this plane) or feed
    the head pre-solve. ``sigma_u = sigma_v = 0`` degenerates to
    :class:`SpectralBeamSource`'s fan lines, started on the plane. Transports on
    both backends via the vectorized pre-sampling route.
    """

    def __init__(
        self,
        spectrum: Spectrum,
        focal_point: tuple[float, float, float],
        center: tuple[float, float, float],
        width_u: float,
        width_v: float,
        sigma_u: float,
        sigma_v: float,
        u_axis: tuple[float, float, float] = (1.0, 0.0, 0.0),
        v_axis: tuple[float, float, float] = (0.0, 1.0, 0.0),
    ) -> None:
        self._spectrum = spectrum
        self._spot_fan = _GaussianSpotFan(
            _DivergentFan(focal_point, (center,), width_u, width_v, u_axis, v_axis),
            sigma_u,
            sigma_v,
        )

    @property
    def max_energy(self) -> float:
        """The spectrum's top bin edge, for cross-section table sizing."""
        return self._spectrum.max_energy

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one primary on the plane; consumes exactly six uniforms."""
        return self._spot_fan.emit_from_plane(self._spectrum, 0, rng_state)

    def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
        """Vectorized simple-route batch; see :meth:`_GaussianSpotFan.sample_from_plane_batch`."""
        return self._spot_fan.sample_from_plane_batch(self._spectrum, 0, seed, history_offset, n)

max_energy property

max_energy: float

The spectrum's top bin edge, for cross-section table sizing.

emit

emit(rng_state: RNGState) -> Primary

Emit one primary on the plane; consumes exactly six uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one primary on the plane; consumes exactly six uniforms."""
    return self._spot_fan.emit_from_plane(self._spectrum, 0, rng_state)

sample_batch

sample_batch(seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]

Vectorized simple-route batch; see :meth:_GaussianSpotFan.sample_from_plane_batch.

Source code in pyradmc/geometry/source.py
def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
    """Vectorized simple-route batch; see :meth:`_GaussianSpotFan.sample_from_plane_batch`."""
    return self._spot_fan.sample_from_plane_batch(self._spectrum, 0, seed, history_offset, n)

pyradmc.geometry.source.GaussianSpotBeamletSource

Bases: BeamletSource

The beamlet-resolved planar Gaussian-spot source (one rectangle per bixel).

Beamlet j emits on the rectangle centred at centers[j]; the draw stream never sees the beamlet, so correlated Dij sampling replays the same energy, in-rectangle offset and spot point in every column. See :class:GaussianSpotBeamSource for the geometry and conventions.

Source code in pyradmc/geometry/source.py
class GaussianSpotBeamletSource(BeamletSource):
    """The beamlet-resolved planar Gaussian-spot source (one rectangle per bixel).

    Beamlet ``j`` emits on the rectangle centred at ``centers[j]``; the draw
    stream never sees the beamlet, so correlated Dij sampling replays the same
    energy, in-rectangle offset and spot point in every column. See
    :class:`GaussianSpotBeamSource` for the geometry and conventions.
    """

    def __init__(
        self,
        spectrum: Spectrum,
        focal_point: tuple[float, float, float],
        centers: Sequence[tuple[float, float, float]],
        width_u: float,
        width_v: float,
        sigma_u: float,
        sigma_v: float,
        u_axis: tuple[float, float, float] = (1.0, 0.0, 0.0),
        v_axis: tuple[float, float, float] = (0.0, 1.0, 0.0),
    ) -> None:
        self._spectrum = spectrum
        self._spot_fan = _GaussianSpotFan(
            _DivergentFan(focal_point, tuple(centers), width_u, width_v, u_axis, v_axis),
            sigma_u,
            sigma_v,
        )

    @property
    def max_energy(self) -> float:
        """The spectrum's top bin edge, for cross-section table sizing."""
        return self._spectrum.max_energy

    @property
    def n_beamlets(self) -> int:
        """One beamlet per plane rectangle centre."""
        return len(self._spot_fan.fan.centers)

    def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
        """Emit one primary for ``beamlet``; consumes exactly six uniforms."""
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
        return self._spot_fan.emit_from_plane(self._spectrum, beamlet, rng_state)

    def sample_beamlet_batch(
        self, seed: int, history_offset: int, n: int, beamlet: int
    ) -> dict[str, npt.NDArray[Any]]:
        """Vectorized per-beamlet batch; chunk-invariant and beamlet-blind."""
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
        return self._spot_fan.sample_from_plane_batch(
            self._spectrum, beamlet, seed, history_offset, n
        )

max_energy property

max_energy: float

The spectrum's top bin edge, for cross-section table sizing.

n_beamlets property

n_beamlets: int

One beamlet per plane rectangle centre.

emit

emit(beamlet: int, rng_state: RNGState) -> Primary

Emit one primary for beamlet; consumes exactly six uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
    """Emit one primary for ``beamlet``; consumes exactly six uniforms."""
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
    return self._spot_fan.emit_from_plane(self._spectrum, beamlet, rng_state)

sample_beamlet_batch

sample_beamlet_batch(seed: int, history_offset: int, n: int, beamlet: int) -> dict[str, npt.NDArray[Any]]

Vectorized per-beamlet batch; chunk-invariant and beamlet-blind.

Source code in pyradmc/geometry/source.py
def sample_beamlet_batch(
    self, seed: int, history_offset: int, n: int, beamlet: int
) -> dict[str, npt.NDArray[Any]]:
    """Vectorized per-beamlet batch; chunk-invariant and beamlet-blind."""
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
    return self._spot_fan.sample_from_plane_batch(
        self._spectrum, beamlet, seed, history_offset, n
    )

pyradmc.geometry.source.PrimaryFluenceBeamSource

Bases: Source

Open field from a measured primary fluence: the Tacke et al. (2006) VSM.

Photons are born on a rectangle of a plane upstream of the beam-limiting devices, aimed from a 2D-Gaussian focal spot, with an energy drawn from spectrum and a statistical weight equal to the machine's measured radial primary fluence at that point. It is :class:GaussianSpotBeamSource plus the fluence shape, and degenerates to it exactly for a table that is flat over the rectangle.

Compose with :class:~pyradmc.geometry.collimation.CollimatedSource to add jaws and an MLC downstream of the plane, or feed it to the head pre-solve. See :class:_PrimaryFluenceFan for the weighting rationale and its cost, and :class:~pyradmc.geometry.fluence.RadialFluence for the table conventions. Transports on both backends via the vectorized pre-sampling route.

Stated approximation: the emitted spectrum is the same at every off-axis radius. Real flattened beams soften off axis (the filter is thicker on the central ray), an effect the source paper models with a radius-dependent spectrum; a caller who needs it can build a :class:CompositeSource of annular components with different spectra.

Source code in pyradmc/geometry/source.py
class PrimaryFluenceBeamSource(Source):
    """Open field from a measured primary fluence: the Tacke et al. (2006) VSM.

    Photons are born on a rectangle of a plane upstream of the beam-limiting
    devices, aimed from a 2D-Gaussian focal spot, with an energy drawn from
    ``spectrum`` and a statistical weight equal to the machine's measured radial
    primary fluence at that point. It is
    :class:`GaussianSpotBeamSource` plus the fluence shape, and degenerates to it
    exactly for a table that is flat over the rectangle.

    Compose with :class:`~pyradmc.geometry.collimation.CollimatedSource` to add
    jaws and an MLC downstream of the plane, or feed it to the head pre-solve.
    See :class:`_PrimaryFluenceFan` for the weighting rationale and its cost, and
    :class:`~pyradmc.geometry.fluence.RadialFluence` for the table conventions.
    Transports on both backends via the vectorized pre-sampling route.

    Stated approximation: the emitted spectrum is the same at every off-axis
    radius. Real flattened beams soften off axis (the filter is thicker on the
    central ray), an effect the source paper models with a radius-dependent
    spectrum; a caller who needs it can build a
    :class:`CompositeSource` of annular components with different spectra.
    """

    def __init__(
        self,
        spectrum: Spectrum,
        fluence: RadialFluence,
        focal_point: tuple[float, float, float],
        center: tuple[float, float, float],
        width_u: float,
        width_v: float,
        sigma_u: float,
        sigma_v: float,
        u_axis: tuple[float, float, float] = (1.0, 0.0, 0.0),
        v_axis: tuple[float, float, float] = (0.0, 1.0, 0.0),
    ) -> None:
        self._spectrum = spectrum
        self._fan = _PrimaryFluenceFan(
            _GaussianSpotFan(
                _DivergentFan(focal_point, (center,), width_u, width_v, u_axis, v_axis),
                sigma_u,
                sigma_v,
            ),
            fluence,
        )

    @property
    def max_energy(self) -> float:
        """The spectrum's top bin edge, for cross-section table sizing."""
        return self._spectrum.max_energy

    @property
    def fluence(self) -> RadialFluence:
        """The measured radial primary fluence weighting each history."""
        return self._fan.fluence

    def weight_at(self, point: tuple[float, float, float]) -> float:
        """Report the weight a primary born at ``point`` on the emission plane carries."""
        return self._fan.weight_at(point)

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one weighted primary on the plane; consumes exactly six uniforms."""
        return self._fan.emit_weighted(self._spectrum, 0, rng_state)

    def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
        """Vectorized simple-route batch; see :meth:`_PrimaryFluenceFan.sample_weighted_batch`."""
        return self._fan.sample_weighted_batch(self._spectrum, 0, seed, history_offset, n)

max_energy property

max_energy: float

The spectrum's top bin edge, for cross-section table sizing.

fluence property

fluence: RadialFluence

The measured radial primary fluence weighting each history.

weight_at

weight_at(point: tuple[float, float, float]) -> float

Report the weight a primary born at point on the emission plane carries.

Source code in pyradmc/geometry/source.py
def weight_at(self, point: tuple[float, float, float]) -> float:
    """Report the weight a primary born at ``point`` on the emission plane carries."""
    return self._fan.weight_at(point)

emit

emit(rng_state: RNGState) -> Primary

Emit one weighted primary on the plane; consumes exactly six uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one weighted primary on the plane; consumes exactly six uniforms."""
    return self._fan.emit_weighted(self._spectrum, 0, rng_state)

sample_batch

sample_batch(seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]

Vectorized simple-route batch; see :meth:_PrimaryFluenceFan.sample_weighted_batch.

Source code in pyradmc/geometry/source.py
def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
    """Vectorized simple-route batch; see :meth:`_PrimaryFluenceFan.sample_weighted_batch`."""
    return self._fan.sample_weighted_batch(self._spectrum, 0, seed, history_offset, n)

pyradmc.geometry.source.PrimaryFluenceBeamletSource

Bases: BeamletSource

The beamlet-resolved primary-fluence source (one plane rectangle per bixel).

Beamlet j emits on the rectangle centred at centers[j], all of which must lie on the one plane the fluence is defined against. The draw stream never sees the beamlet, so correlated Dij sampling replays the same energy, in-rectangle offset and spot point in every column; the weights do differ between columns, which is the point — each bixel sits at its own off-axis radius and therefore its own primary fluence. See :class:PrimaryFluenceBeamSource for the geometry and the stated approximation.

Source code in pyradmc/geometry/source.py
class PrimaryFluenceBeamletSource(BeamletSource):
    """The beamlet-resolved primary-fluence source (one plane rectangle per bixel).

    Beamlet ``j`` emits on the rectangle centred at ``centers[j]``, all of which
    must lie on the one plane the fluence is defined against. The draw stream
    never sees the beamlet, so correlated Dij sampling replays the same energy,
    in-rectangle offset and spot point in every column; the **weights** do differ
    between columns, which is the point — each bixel sits at its own off-axis
    radius and therefore its own primary fluence. See
    :class:`PrimaryFluenceBeamSource` for the geometry and the stated
    approximation.
    """

    def __init__(
        self,
        spectrum: Spectrum,
        fluence: RadialFluence,
        focal_point: tuple[float, float, float],
        centers: Sequence[tuple[float, float, float]],
        width_u: float,
        width_v: float,
        sigma_u: float,
        sigma_v: float,
        u_axis: tuple[float, float, float] = (1.0, 0.0, 0.0),
        v_axis: tuple[float, float, float] = (0.0, 1.0, 0.0),
    ) -> None:
        self._spectrum = spectrum
        self._fan = _PrimaryFluenceFan(
            _GaussianSpotFan(
                _DivergentFan(focal_point, tuple(centers), width_u, width_v, u_axis, v_axis),
                sigma_u,
                sigma_v,
            ),
            fluence,
        )

    @property
    def max_energy(self) -> float:
        """The spectrum's top bin edge, for cross-section table sizing."""
        return self._spectrum.max_energy

    @property
    def n_beamlets(self) -> int:
        """One beamlet per plane rectangle centre."""
        return len(self._fan.spot.fan.centers)

    @property
    def fluence(self) -> RadialFluence:
        """The measured radial primary fluence weighting each history."""
        return self._fan.fluence

    def weight_at(self, point: tuple[float, float, float]) -> float:
        """Report the weight a primary born at ``point`` on the emission plane carries."""
        return self._fan.weight_at(point)

    def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
        """Emit one weighted primary for ``beamlet``; consumes exactly six uniforms."""
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
        return self._fan.emit_weighted(self._spectrum, beamlet, rng_state)

    def sample_beamlet_batch(
        self, seed: int, history_offset: int, n: int, beamlet: int
    ) -> dict[str, npt.NDArray[Any]]:
        """Vectorized per-beamlet batch; the draws are beamlet-blind, the weights are not."""
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
        return self._fan.sample_weighted_batch(self._spectrum, beamlet, seed, history_offset, n)

max_energy property

max_energy: float

The spectrum's top bin edge, for cross-section table sizing.

n_beamlets property

n_beamlets: int

One beamlet per plane rectangle centre.

fluence property

fluence: RadialFluence

The measured radial primary fluence weighting each history.

weight_at

weight_at(point: tuple[float, float, float]) -> float

Report the weight a primary born at point on the emission plane carries.

Source code in pyradmc/geometry/source.py
def weight_at(self, point: tuple[float, float, float]) -> float:
    """Report the weight a primary born at ``point`` on the emission plane carries."""
    return self._fan.weight_at(point)

emit

emit(beamlet: int, rng_state: RNGState) -> Primary

Emit one weighted primary for beamlet; consumes exactly six uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
    """Emit one weighted primary for ``beamlet``; consumes exactly six uniforms."""
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
    return self._fan.emit_weighted(self._spectrum, beamlet, rng_state)

sample_beamlet_batch

sample_beamlet_batch(seed: int, history_offset: int, n: int, beamlet: int) -> dict[str, npt.NDArray[Any]]

Vectorized per-beamlet batch; the draws are beamlet-blind, the weights are not.

Source code in pyradmc/geometry/source.py
def sample_beamlet_batch(
    self, seed: int, history_offset: int, n: int, beamlet: int
) -> dict[str, npt.NDArray[Any]]:
    """Vectorized per-beamlet batch; the draws are beamlet-blind, the weights are not."""
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
    return self._fan.sample_weighted_batch(self._spectrum, beamlet, seed, history_offset, n)

pyradmc.geometry.source.SpectralBeamSource

Bases: Source

Divergent polyenergetic open field: a focal spot fanning through one aperture.

The photon energy is sampled from a histogram :class:~pyradmc.geometry.spectrum.Spectrum by CDF inversion; the geometry is a point source at focal_point emitting toward points sampled uniformly in the rectangular aperture at the reference plane (see :class:_DivergentFan for the engine-frame convention and axis semantics). The Warp engine routes this exact type to a built-in in-kernel generator (CDF inversion over the uploaded :attr:spectrum tables — no host round trip; the host route was measured wall-dominant on CT-grade runs); the reference backend uses :meth:emit, and a subclass transports via the pre-sampling route, since an override may change what a history means.

Source code in pyradmc/geometry/source.py
class SpectralBeamSource(Source):
    """Divergent polyenergetic open field: a focal spot fanning through one aperture.

    The photon energy is sampled from a histogram
    :class:`~pyradmc.geometry.spectrum.Spectrum` by CDF inversion; the geometry is a
    point source at ``focal_point`` emitting toward points sampled uniformly in the
    rectangular aperture at the reference plane (see :class:`_DivergentFan` for the
    engine-frame convention and axis semantics). The Warp engine routes this exact
    type to a built-in in-kernel generator (CDF inversion over the uploaded
    :attr:`spectrum` tables — no host round trip; the host route was measured
    wall-dominant on CT-grade runs); the reference backend uses :meth:`emit`, and a
    *subclass* transports via the pre-sampling route, since an override may change
    what a history means.
    """

    def __init__(
        self,
        spectrum: Spectrum,
        focal_point: tuple[float, float, float],
        center: tuple[float, float, float],
        width_u: float,
        width_v: float,
        u_axis: tuple[float, float, float] = (1.0, 0.0, 0.0),
        v_axis: tuple[float, float, float] = (0.0, 1.0, 0.0),
    ) -> None:
        self._spectrum = spectrum
        self._fan = _DivergentFan(focal_point, (center,), width_u, width_v, u_axis, v_axis)

    @property
    def max_energy(self) -> float:
        """The spectrum's top bin edge, for cross-section table sizing."""
        return self._spectrum.max_energy

    @property
    def spectrum(self) -> Spectrum:
        """The histogram spectrum energies are drawn from."""
        return self._spectrum

    @property
    def focal_point(self) -> tuple[float, float, float]:
        """The point source position, engine frame (cm)."""
        return self._fan.focal_point

    @property
    def center(self) -> tuple[float, float, float]:
        """The aperture centre on the reference plane, engine frame (cm)."""
        return self._fan.centers[0]

    @property
    def width_u(self) -> float:
        """Aperture width along :attr:`u_axis` (cm)."""
        return self._fan.width_u

    @property
    def width_v(self) -> float:
        """Aperture width along :attr:`v_axis` (cm)."""
        return self._fan.width_v

    @property
    def u_axis(self) -> tuple[float, float, float]:
        """First aperture axis (unit vector after fan validation)."""
        return self._fan.u_axis

    @property
    def v_axis(self) -> tuple[float, float, float]:
        """Second aperture axis (unit vector after fan validation)."""
        return self._fan.v_axis

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one primary through the aperture; consumes exactly four uniforms."""
        return self._fan.emit_through(self._spectrum, 0, rng_state)

    def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
        """Vectorized simple-route batch; see :meth:`_DivergentFan.sample_through_batch`."""
        return self._fan.sample_through_batch(self._spectrum, 0, seed, history_offset, n)

max_energy property

max_energy: float

The spectrum's top bin edge, for cross-section table sizing.

spectrum property

spectrum: Spectrum

The histogram spectrum energies are drawn from.

focal_point property

focal_point: tuple[float, float, float]

The point source position, engine frame (cm).

center property

center: tuple[float, float, float]

The aperture centre on the reference plane, engine frame (cm).

width_u property

width_u: float

Aperture width along :attr:u_axis (cm).

width_v property

width_v: float

Aperture width along :attr:v_axis (cm).

u_axis property

u_axis: tuple[float, float, float]

First aperture axis (unit vector after fan validation).

v_axis property

v_axis: tuple[float, float, float]

Second aperture axis (unit vector after fan validation).

emit

emit(rng_state: RNGState) -> Primary

Emit one primary through the aperture; consumes exactly four uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one primary through the aperture; consumes exactly four uniforms."""
    return self._fan.emit_through(self._spectrum, 0, rng_state)

sample_batch

sample_batch(seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]

Vectorized simple-route batch; see :meth:_DivergentFan.sample_through_batch.

Source code in pyradmc/geometry/source.py
def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, npt.NDArray[Any]]:
    """Vectorized simple-route batch; see :meth:`_DivergentFan.sample_through_batch`."""
    return self._fan.sample_through_batch(self._spectrum, 0, seed, history_offset, n)

pyradmc.geometry.source.SpectralBeamletSource

Bases: BeamletSource

Divergent polyenergetic beamlet fan — the pyRadPlan adapter's Dij source.

Beamlet j is the fan from focal_point through the rectangular aperture centred at centers[j] (all engine-frame; see :class:_DivergentFan). The beamlet order is the caller's: pyRadPlan hands the centres in its own bixel-index order and the Dij columns come back in the same order. All bixels share one aperture size, the width at the reference plane the centres lie on. The Warp engine routes this exact type to a built-in in-kernel generator (the host per-beamlet pre-sampling was measured wall-dominant on CT-grade Dij runs); the reference backend uses :meth:emit, and a subclass transports via the pre-sampling route, since an override may change what a history means.

Source code in pyradmc/geometry/source.py
class SpectralBeamletSource(BeamletSource):
    """Divergent polyenergetic beamlet fan — the pyRadPlan adapter's Dij source.

    Beamlet ``j`` is the fan from ``focal_point`` through the rectangular aperture
    centred at ``centers[j]`` (all engine-frame; see :class:`_DivergentFan`). The
    beamlet order is the caller's: pyRadPlan hands the centres in its own
    bixel-index order and the Dij columns come back in the same order. All bixels
    share one aperture size, the width at the reference plane the centres lie on.
    The Warp engine routes this exact type to a built-in in-kernel generator (the
    host per-beamlet pre-sampling was measured wall-dominant on CT-grade Dij
    runs); the reference backend uses :meth:`emit`, and a *subclass* transports
    via the pre-sampling route, since an override may change what a history means.
    """

    def __init__(
        self,
        spectrum: Spectrum,
        focal_point: tuple[float, float, float],
        centers: Sequence[tuple[float, float, float]],
        width_u: float,
        width_v: float,
        u_axis: tuple[float, float, float] = (1.0, 0.0, 0.0),
        v_axis: tuple[float, float, float] = (0.0, 1.0, 0.0),
    ) -> None:
        self._spectrum = spectrum
        self._fan = _DivergentFan(focal_point, tuple(centers), width_u, width_v, u_axis, v_axis)

    @property
    def max_energy(self) -> float:
        """The spectrum's top bin edge, for cross-section table sizing."""
        return self._spectrum.max_energy

    @property
    def n_beamlets(self) -> int:
        """One beamlet per aperture centre."""
        return len(self._fan.centers)

    @property
    def spectrum(self) -> Spectrum:
        """The histogram spectrum energies are drawn from."""
        return self._spectrum

    @property
    def focal_point(self) -> tuple[float, float, float]:
        """The point source position, engine frame (cm)."""
        return self._fan.focal_point

    @property
    def centers(self) -> tuple[tuple[float, float, float], ...]:
        """The beamlet aperture centres, engine frame (cm), in the caller's order."""
        return self._fan.centers

    @property
    def width_u(self) -> float:
        """Aperture width along :attr:`u_axis` (cm), shared by every beamlet."""
        return self._fan.width_u

    @property
    def width_v(self) -> float:
        """Aperture width along :attr:`v_axis` (cm), shared by every beamlet."""
        return self._fan.width_v

    @property
    def u_axis(self) -> tuple[float, float, float]:
        """First aperture axis (unit vector after fan validation)."""
        return self._fan.u_axis

    @property
    def v_axis(self) -> tuple[float, float, float]:
        """Second aperture axis (unit vector after fan validation)."""
        return self._fan.v_axis

    def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
        """Emit one primary for ``beamlet``; consumes exactly four uniforms."""
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
        return self._fan.emit_through(self._spectrum, beamlet, rng_state)

    def sample_beamlet_batch(
        self, seed: int, history_offset: int, n: int, beamlet: int
    ) -> dict[str, npt.NDArray[Any]]:
        """Vectorized per-beamlet batch; see :meth:`_DivergentFan.sample_through_batch`.

        The caller keys ``history_offset`` for the correlated/independent mapping;
        the draw stream never sees the beamlet, so correlated sampling replays the
        same energy and in-aperture offset in every beamlet's column (test-pinned).
        """
        if not 0 <= beamlet < self.n_beamlets:
            raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
        return self._fan.sample_through_batch(self._spectrum, beamlet, seed, history_offset, n)

max_energy property

max_energy: float

The spectrum's top bin edge, for cross-section table sizing.

n_beamlets property

n_beamlets: int

One beamlet per aperture centre.

spectrum property

spectrum: Spectrum

The histogram spectrum energies are drawn from.

focal_point property

focal_point: tuple[float, float, float]

The point source position, engine frame (cm).

centers property

centers: tuple[tuple[float, float, float], ...]

The beamlet aperture centres, engine frame (cm), in the caller's order.

width_u property

width_u: float

Aperture width along :attr:u_axis (cm), shared by every beamlet.

width_v property

width_v: float

Aperture width along :attr:v_axis (cm), shared by every beamlet.

u_axis property

u_axis: tuple[float, float, float]

First aperture axis (unit vector after fan validation).

v_axis property

v_axis: tuple[float, float, float]

Second aperture axis (unit vector after fan validation).

emit

emit(beamlet: int, rng_state: RNGState) -> Primary

Emit one primary for beamlet; consumes exactly four uniforms.

Source code in pyradmc/geometry/source.py
def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
    """Emit one primary for ``beamlet``; consumes exactly four uniforms."""
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
    return self._fan.emit_through(self._spectrum, beamlet, rng_state)

sample_beamlet_batch

sample_beamlet_batch(seed: int, history_offset: int, n: int, beamlet: int) -> dict[str, npt.NDArray[Any]]

Vectorized per-beamlet batch; see :meth:_DivergentFan.sample_through_batch.

The caller keys history_offset for the correlated/independent mapping; the draw stream never sees the beamlet, so correlated sampling replays the same energy and in-aperture offset in every beamlet's column (test-pinned).

Source code in pyradmc/geometry/source.py
def sample_beamlet_batch(
    self, seed: int, history_offset: int, n: int, beamlet: int
) -> dict[str, npt.NDArray[Any]]:
    """Vectorized per-beamlet batch; see :meth:`_DivergentFan.sample_through_batch`.

    The caller keys ``history_offset`` for the correlated/independent mapping;
    the draw stream never sees the beamlet, so correlated sampling replays the
    same energy and in-aperture offset in every beamlet's column (test-pinned).
    """
    if not 0 <= beamlet < self.n_beamlets:
        raise IndexError(f"beamlet {beamlet} outside fan of {self.n_beamlets}")
    return self._fan.sample_through_batch(self._spectrum, beamlet, seed, history_offset, n)

pyradmc.geometry.source.CompositeSource

Bases: Source

A mixture of open-field sources — the virtual-source-model building block.

Each history is emitted by one component, chosen with probability proportional to its weight (a single uniform draw), then that component's emit runs. So a beam modelled as, e.g., a narrow Gaussian core plus a broad scatter tail is CompositeSource([(core, 0.85), (tail, 0.15)]). weight is the selection probability, which equals the fluence fraction for unit-weight components; a component that itself carries a per-primary weight (a phase space) has that weight multiplied on top.

Composites transport on both backends through the pre-sampling route (they carry no warp_sampler); a mixture wanting the in-kernel route writes a single warp_sampler that branches internally.

Source code in pyradmc/geometry/source.py
class CompositeSource(Source):
    """A mixture of open-field sources — the virtual-source-model building block.

    Each history is emitted by one component, chosen with probability proportional to
    its weight (a single uniform draw), then that component's ``emit`` runs. So a beam
    modelled as, e.g., a narrow Gaussian core plus a broad scatter tail is
    ``CompositeSource([(core, 0.85), (tail, 0.15)])``. ``weight`` is the *selection*
    probability, which equals the fluence fraction for unit-weight components; a
    component that itself carries a per-primary weight (a phase space) has that weight
    multiplied on top.

    Composites transport on both backends through the pre-sampling route (they carry no
    ``warp_sampler``); a mixture wanting the in-kernel route writes a single
    ``warp_sampler`` that branches internally.
    """

    def __init__(self, components: Sequence[tuple[Source, float]]) -> None:
        if not components:
            raise ValueError("a composite source needs at least one component")
        self._sources = tuple(source for source, _ in components)
        self._cdf = _selection_cdf([weight for _, weight in components])
        self._max_energy = max(source.max_energy for source in self._sources)

    @property
    def max_energy(self) -> float:
        """Highest energy any component can emit, for cross-section table sizing."""
        return self._max_energy

    def emit(self, rng_state: RNGState) -> Primary:
        """Choose a component by weight (one uniform), then emit from it."""
        index = min(int(np.searchsorted(self._cdf, uniform(rng_state))), len(self._sources) - 1)
        return self._sources[index].emit(rng_state)

max_energy property

max_energy: float

Highest energy any component can emit, for cross-section table sizing.

emit

emit(rng_state: RNGState) -> Primary

Choose a component by weight (one uniform), then emit from it.

Source code in pyradmc/geometry/source.py
def emit(self, rng_state: RNGState) -> Primary:
    """Choose a component by weight (one uniform), then emit from it."""
    index = min(int(np.searchsorted(self._cdf, uniform(rng_state))), len(self._sources) - 1)
    return self._sources[index].emit(rng_state)

pyradmc.geometry.source.CompositeBeamletSource

Bases: BeamletSource

A per-beamlet mixture of beamlet sources — a VSM for beamlet-resolved dose.

Every component describes the same beamlets (identical n_beamlets), so beamlet j is a mixture: :meth:emit chooses a component by weight (one uniform) and emits that component's beamlet j. Assembling the Dij then gives each beamlet's column as the virtual-source-model dose. Like :class:CompositeSource, it runs on both backends through the pre-sampling Dij route; weight is the selection probability. (The Dij transports each beamlet primary as a unit-weight photon, so components should be photon beamlet sources.)

Source code in pyradmc/geometry/source.py
class CompositeBeamletSource(BeamletSource):
    """A per-beamlet mixture of beamlet sources — a VSM for beamlet-resolved dose.

    Every component describes the *same* beamlets (identical ``n_beamlets``), so beamlet
    ``j`` is a mixture: :meth:`emit` chooses a component by weight (one uniform) and
    emits that component's beamlet ``j``. Assembling the Dij then gives each beamlet's
    column as the virtual-source-model dose. Like :class:`CompositeSource`, it runs on
    both backends through the pre-sampling Dij route; ``weight`` is the selection
    probability. (The Dij transports each beamlet primary as a unit-weight photon, so
    components should be photon beamlet sources.)
    """

    def __init__(self, components: Sequence[tuple[BeamletSource, float]]) -> None:
        if not components:
            raise ValueError("a composite beamlet source needs at least one component")
        self._sources = tuple(source for source, _ in components)
        counts = {source.n_beamlets for source in self._sources}
        if len(counts) != 1:
            raise ValueError(f"all components must share n_beamlets, got {sorted(counts)}")
        self._n_beamlets = counts.pop()
        self._cdf = _selection_cdf([weight for _, weight in components])
        self._max_energy = max(source.max_energy for source in self._sources)

    @property
    def max_energy(self) -> float:
        """Highest energy any component can emit, for cross-section table sizing."""
        return self._max_energy

    @property
    def n_beamlets(self) -> int:
        """The shared beamlet count of every component."""
        return self._n_beamlets

    def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
        """Choose a component by weight (one uniform), then emit its ``beamlet``."""
        index = min(int(np.searchsorted(self._cdf, uniform(rng_state))), len(self._sources) - 1)
        return self._sources[index].emit(beamlet, rng_state)

max_energy property

max_energy: float

Highest energy any component can emit, for cross-section table sizing.

n_beamlets property

n_beamlets: int

The shared beamlet count of every component.

emit

emit(beamlet: int, rng_state: RNGState) -> Primary

Choose a component by weight (one uniform), then emit its beamlet.

Source code in pyradmc/geometry/source.py
def emit(self, beamlet: int, rng_state: RNGState) -> Primary:
    """Choose a component by weight (one uniform), then emit its ``beamlet``."""
    index = min(int(np.searchsorted(self._cdf, uniform(rng_state))), len(self._sources) - 1)
    return self._sources[index].emit(beamlet, rng_state)

pyradmc.geometry.phasespace.PhaseSpaceSource

Bases: Source

A primary source that samples particles from an IAEA phase-space file.

Unlike the analytic beam sources, a phase-space file is a recorded mix of photons, electrons and positrons at non-unit statistical weights; each emitted :class:~pyradmc.geometry.source.Primary therefore carries its own kind and weight. :meth:emit draws one record per history from the history's RNG stream (uniform random sampling with replacement), so the source stays a pure function of (seed, history) like the rest of the engine.

Streaming. The file is memory-mapped, not read into RAM: only the header plus a one-pass scan of the per-record type byte are held eagerly, and each :meth:emit decodes a single record on demand. A multi-gigabyte, tens-of- millions-of-particle file (a real linac phase space) is therefore usable without loading it — resident memory tracks the pages actually touched.

Unsupported particles. A photon MC transports only photons, electrons and positrons; a real file may carry the odd neutron/proton (IAEA codes 4/5). With skip_unsupported (the default) those records are excluded from the sampled population and their count is warned — one stray particle in tens of millions should not reject the file, and excluding a ~1e-8 fraction is negligible. With skip_unsupported=False any unsupported record makes construction raise.

Because a phase-space particle can be any type at any position, this source breaks the unique beamlet ownership the Dij design relies on; it plugs into run on either backend, never run_dij. Per-history :meth:emit serves the reference engine; :meth:sample_batch serves the warp engine, which transports a whole chunk at once (docs/decisions.md).

Source code in pyradmc/geometry/phasespace.py
class PhaseSpaceSource(Source):
    """A primary source that samples particles from an IAEA phase-space file.

    Unlike the analytic beam sources, a phase-space file is a recorded mix of
    photons, electrons and positrons at non-unit statistical weights; each emitted
    :class:`~pyradmc.geometry.source.Primary` therefore carries its own ``kind`` and
    ``weight``. :meth:`emit` draws one record per history from the history's RNG
    stream (uniform random sampling with replacement), so the source stays a pure
    function of ``(seed, history)`` like the rest of the engine.

    **Streaming.** The file is memory-mapped, not read into RAM: only the header
    plus a one-pass scan of the per-record type byte are held eagerly, and each
    :meth:`emit` decodes a single record on demand. A multi-gigabyte, tens-of-
    millions-of-particle file (a real linac phase space) is therefore usable
    without loading it — resident memory tracks the pages actually touched.

    **Unsupported particles.** A photon MC transports only photons, electrons and
    positrons; a real file may carry the odd neutron/proton (IAEA codes 4/5). With
    ``skip_unsupported`` (the default) those records are excluded from the sampled
    population and their count is warned — one stray particle in tens of millions
    should not reject the file, and excluding a ~1e-8 fraction is negligible. With
    ``skip_unsupported=False`` any unsupported record makes construction raise.

    Because a phase-space particle can be any type at any position, this source
    breaks the unique beamlet ownership the Dij design relies on; it plugs into
    ``run`` on either backend, never ``run_dij``. Per-history :meth:`emit` serves
    the reference engine; :meth:`sample_batch` serves the warp engine, which
    transports a whole chunk at once (docs/decisions.md).
    """

    def __init__(self, path: Path | str, *, skip_unsupported: bool = True) -> None:
        self.header = read_iaea_header(path)
        _, self._phsp_path = _iaea_path(path)
        self._struct = _build_struct(self.header)
        self._record_dtype = _record_dtype(self.header)
        self._reclen = self.header.record_length
        n = self.header.n_particles
        if n == 0:
            raise ValueError(f"phase-space file {path} contains no particles")

        self._file: BinaryIO | None = self._phsp_path.open("rb")
        self._mmap = mmap.mmap(self._file.fileno(), 0, access=mmap.ACCESS_READ)

        # One vectorized pass reading the per-record type byte (offset 0) and energy
        # float (offset 1) via a strided structured view, to (a) find records this MC
        # cannot transport and (b) get the maximum energy for sizing the transport
        # tables. The owned columns are copied out and the view dropped immediately:
        # a view left alive (e.g. held by a traceback if the code below raises) would
        # keep the map's buffer exported and block closing it in __del__.
        scan_dtype = np.dtype(
            {
                "names": ["typ", "e"],
                "formats": [np.int8, self.header.byte_order + "f4"],
                "offsets": [0, 1],
                "itemsize": self._reclen,
            }
        )
        view = np.frombuffer(self._mmap, dtype=scan_dtype, count=n)
        codes = np.abs(view["typ"].astype(np.int16))
        energies = np.abs(view["e"].astype(np.float64))
        del view
        self._max_energy = float(energies.max())
        supported = (codes == IAEA_PHOTON) | (codes == IAEA_ELECTRON) | (codes == IAEA_POSITRON)
        unsupported = np.flatnonzero(~supported)
        if unsupported.size:
            if not skip_unsupported:
                first = int(codes[unsupported[0]])
                raise ValueError(
                    f"unsupported particle type {first} in {path}; this photon MC "
                    "transports only photons, electrons and positrons "
                    "(pass skip_unsupported=True to drop them)"
                )
            warnings.warn(
                f"{unsupported.size} of {n} records in {self._phsp_path.name} are "
                "unsupported particle types (not photon/electron/positron); "
                "dropping them from the sampled population",
                stacklevel=2,
            )
        # Sorted, and tiny in the expected case; used to remap a compact sample
        # index over the supported subset back to the record's file position.
        self._unsupported: tuple[int, ...] = tuple(int(i) for i in unsupported)
        self._n_valid = n - len(self._unsupported)
        if self._n_valid == 0:
            raise ValueError(f"phase-space file {path} has no transportable particles")
        self._tripwire = _LatentVarianceTripwire(self._n_valid)

    @property
    def max_energy(self) -> float:
        """Highest particle energy in the file, in MeV (for table sizing)."""
        return self._max_energy

    def _record_index(self, k: int) -> int:
        """Map the k-th *supported* record to its position in the full file.

        Walks the (sorted, usually empty) unsupported list, bumping the target
        index past each unsupported record at or below it.
        """
        actual = k
        for u in self._unsupported:
            if u <= actual:
                actual += 1
            else:
                break
        return actual

    def __len__(self) -> int:
        """Return the number of transportable particles available to sample."""
        return self._n_valid

    def _record_for_state(self, rng_state: RNGState) -> PhspRecord:
        """Sample one supported record from the file; consumes one uniform."""
        k = int(uniform(rng_state) * self._n_valid)
        if k >= self._n_valid:  # guard the uniform == 1.0 corner
            k = self._n_valid - 1
        idx = self._record_index(k)
        off = idx * self._reclen
        return _decode_record(self._mmap[off : off + self._reclen], self.header, self._struct)

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one primary, sampled uniformly from the file; consumes one uniform."""
        self._tripwire.count_one()
        rec = self._record_for_state(rng_state)
        return Primary(
            energy=rec.energy,
            x=rec.x,
            y=rec.y,
            z=rec.z,
            ux=rec.u,
            uy=rec.v,
            uz=rec.w,
            kind=_IAEA_KIND[rec.particle_type],
            weight=rec.weight,
        )

    def _record_indices(self, k: np.ndarray) -> np.ndarray:
        """Vectorized :meth:`_record_index`: map supported ranks to file positions."""
        idx = k.astype(np.int64, copy=True)
        for u in self._unsupported:  # sorted ascending, tiny in the expected case
            idx += idx >= u
        return idx

    def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, np.ndarray]:
        """Sample ``n`` primaries for histories ``[history_offset, history_offset + n)``.

        Returns the primaries as column arrays for bulk upload to a device backend,
        keyed ``particle_type`` (IAEA code 1/2/3), ``energy``, ``x/y/z``, ``ux/uy/uz``
        and ``weight``, geometry as float32 to match the device queue. Sampling and
        decoding are both vectorized: record indices come from a single ``PCG64(seed)``
        stream advanced to ``history_offset``, so history ``h`` always draws the
        ``h``-th value regardless of chunking (chunk-invariant), and the records are
        gathered from the mmap through :data:`_record_dtype` in one fancy-indexed read.

        This is a *different* stream from the reference :meth:`emit` (which spawns a
        per-history generator), so the two backends draw different records — both
        unbiased estimators of the same dose, compared statistically, never bit-wise.
        """
        self._tripwire.note(history_offset + n)
        k = _sample_indices(seed, history_offset, n, self._n_valid)
        recs = np.frombuffer(self._mmap, dtype=self._record_dtype, count=self.header.n_particles)[
            self._record_indices(k)
        ]

        typ = recs["typ"].astype(np.int32)
        sign_w = np.where(typ < 0, np.float32(-1.0), np.float32(1.0))
        stored_names = recs.dtype.names

        def col(name: str) -> np.ndarray:
            if name in stored_names:
                return np.asarray(recs[name], dtype=np.float32)
            return np.full(n, self.header.constants[name], dtype=np.float32)

        u = col("u")
        v = col("v")
        tmp = u.astype(np.float64) ** 2 + v.astype(np.float64) ** 2
        w = np.where(tmp <= 1.0, sign_w * np.sqrt(np.maximum(0.0, 1.0 - tmp)), 0.0).astype(
            np.float32
        )
        over = tmp > 1.0
        if over.any():  # degenerate direction: renormalize u, v (w stays 0)
            scale = np.sqrt(tmp[over]).astype(np.float32)
            u = u.copy()
            v = v.copy()
            u[over] /= scale
            v[over] /= scale

        return {
            "particle_type": np.abs(typ),
            "energy": np.abs(recs["e"].astype(np.float32)),
            "x": col("x"),
            "y": col("y"),
            "z": col("z"),
            "ux": u,
            "uy": v,
            "uz": w,
            "weight": col("weight"),
        }

    def close(self) -> None:
        """Release the memory map and file handle."""
        mm = getattr(self, "_mmap", None)
        if mm is not None:
            mm.close()
            self._mmap = None  # type: ignore[assignment]
        if getattr(self, "_file", None) is not None:
            assert self._file is not None
            self._file.close()
            self._file = None

    def __enter__(self) -> PhaseSpaceSource:
        """Return self; the map is already open from construction."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Release the memory map and file handle."""
        self.close()

    def __del__(self) -> None:
        """Best-effort cleanup if the source was not closed explicitly."""
        self.close()

max_energy property

max_energy: float

Highest particle energy in the file, in MeV (for table sizing).

emit

emit(rng_state: RNGState) -> Primary

Emit one primary, sampled uniformly from the file; consumes one uniform.

Source code in pyradmc/geometry/phasespace.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one primary, sampled uniformly from the file; consumes one uniform."""
    self._tripwire.count_one()
    rec = self._record_for_state(rng_state)
    return Primary(
        energy=rec.energy,
        x=rec.x,
        y=rec.y,
        z=rec.z,
        ux=rec.u,
        uy=rec.v,
        uz=rec.w,
        kind=_IAEA_KIND[rec.particle_type],
        weight=rec.weight,
    )

sample_batch

sample_batch(seed: int, history_offset: int, n: int) -> dict[str, np.ndarray]

Sample n primaries for histories [history_offset, history_offset + n).

Returns the primaries as column arrays for bulk upload to a device backend, keyed particle_type (IAEA code 1/2/3), energy, x/y/z, ux/uy/uz and weight, geometry as float32 to match the device queue. Sampling and decoding are both vectorized: record indices come from a single PCG64(seed) stream advanced to history_offset, so history h always draws the h-th value regardless of chunking (chunk-invariant), and the records are gathered from the mmap through :data:_record_dtype in one fancy-indexed read.

This is a different stream from the reference :meth:emit (which spawns a per-history generator), so the two backends draw different records — both unbiased estimators of the same dose, compared statistically, never bit-wise.

Source code in pyradmc/geometry/phasespace.py
def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, np.ndarray]:
    """Sample ``n`` primaries for histories ``[history_offset, history_offset + n)``.

    Returns the primaries as column arrays for bulk upload to a device backend,
    keyed ``particle_type`` (IAEA code 1/2/3), ``energy``, ``x/y/z``, ``ux/uy/uz``
    and ``weight``, geometry as float32 to match the device queue. Sampling and
    decoding are both vectorized: record indices come from a single ``PCG64(seed)``
    stream advanced to ``history_offset``, so history ``h`` always draws the
    ``h``-th value regardless of chunking (chunk-invariant), and the records are
    gathered from the mmap through :data:`_record_dtype` in one fancy-indexed read.

    This is a *different* stream from the reference :meth:`emit` (which spawns a
    per-history generator), so the two backends draw different records — both
    unbiased estimators of the same dose, compared statistically, never bit-wise.
    """
    self._tripwire.note(history_offset + n)
    k = _sample_indices(seed, history_offset, n, self._n_valid)
    recs = np.frombuffer(self._mmap, dtype=self._record_dtype, count=self.header.n_particles)[
        self._record_indices(k)
    ]

    typ = recs["typ"].astype(np.int32)
    sign_w = np.where(typ < 0, np.float32(-1.0), np.float32(1.0))
    stored_names = recs.dtype.names

    def col(name: str) -> np.ndarray:
        if name in stored_names:
            return np.asarray(recs[name], dtype=np.float32)
        return np.full(n, self.header.constants[name], dtype=np.float32)

    u = col("u")
    v = col("v")
    tmp = u.astype(np.float64) ** 2 + v.astype(np.float64) ** 2
    w = np.where(tmp <= 1.0, sign_w * np.sqrt(np.maximum(0.0, 1.0 - tmp)), 0.0).astype(
        np.float32
    )
    over = tmp > 1.0
    if over.any():  # degenerate direction: renormalize u, v (w stays 0)
        scale = np.sqrt(tmp[over]).astype(np.float32)
        u = u.copy()
        v = v.copy()
        u[over] /= scale
        v[over] /= scale

    return {
        "particle_type": np.abs(typ),
        "energy": np.abs(recs["e"].astype(np.float32)),
        "x": col("x"),
        "y": col("y"),
        "z": col("z"),
        "ux": u,
        "uy": v,
        "uz": w,
        "weight": col("weight"),
    }

close

close() -> None

Release the memory map and file handle.

Source code in pyradmc/geometry/phasespace.py
def close(self) -> None:
    """Release the memory map and file handle."""
    mm = getattr(self, "_mmap", None)
    if mm is not None:
        mm.close()
        self._mmap = None  # type: ignore[assignment]
    if getattr(self, "_file", None) is not None:
        assert self._file is not None
        self._file.close()
        self._file = None

pyradmc.geometry.phasespace.InMemoryPhaseSpaceSource

Bases: Source

A phase space held as column arrays — the treatment-head pre-solve's output.

The same sampling contract as :class:PhaseSpaceSource (one uniform per emit; chunk-invariant vectorized :meth:sample_batch on its own PCG64 stream; per-particle kind and weight) backed by arrays built in memory instead of an IAEA file, so geometry/head.py can hand its scored exit-plane particles straight to run. Like the file-backed source it breaks unique beamlet ownership and never plugs into run_dij.

Directions must be unit vectors (validated to 1e-5 — pre-solve output is float64, so this is a correctness check, not a tolerance); particle_type uses the IAEA codes (photon 1, electron 2, positron 3). The finite-reuse latent-variance caveat (:class:_LatentVarianceTripwire) applies with force here: the population size is whatever the pre-solve was asked for, so oversampling it is easy — size the pre-solve at or above the planned transport histories.

Source code in pyradmc/geometry/phasespace.py
class InMemoryPhaseSpaceSource(Source):
    """A phase space held as column arrays — the treatment-head pre-solve's output.

    The same sampling contract as :class:`PhaseSpaceSource` (one uniform per
    ``emit``; chunk-invariant vectorized :meth:`sample_batch` on its own PCG64
    stream; per-particle ``kind`` and ``weight``) backed by arrays built in
    memory instead of an IAEA file, so ``geometry/head.py`` can hand its scored
    exit-plane particles straight to ``run``. Like the file-backed source it
    breaks unique beamlet ownership and never plugs into ``run_dij``.

    Directions must be unit vectors (validated to 1e-5 — pre-solve output is
    float64, so this is a correctness check, not a tolerance); ``particle_type``
    uses the IAEA codes (photon 1, electron 2, positron 3). The finite-reuse
    latent-variance caveat (:class:`_LatentVarianceTripwire`) applies with force
    here: the population size is whatever the pre-solve was asked for, so
    oversampling it is easy — size the pre-solve at or above the planned
    transport histories.
    """

    def __init__(
        self,
        particle_type: np.ndarray,
        energy: np.ndarray,
        x: np.ndarray,
        y: np.ndarray,
        z: np.ndarray,
        ux: np.ndarray,
        uy: np.ndarray,
        uz: np.ndarray,
        weight: np.ndarray,
    ) -> None:
        codes = np.asarray(particle_type, dtype=np.int32)
        if codes.size == 0:
            raise ValueError("an in-memory phase space needs at least one particle")
        columns = {
            "energy": energy,
            "x": x,
            "y": y,
            "z": z,
            "ux": ux,
            "uy": uy,
            "uz": uz,
            "weight": weight,
        }
        arrays = {name: np.asarray(col, dtype=np.float64) for name, col in columns.items()}
        for name, col in arrays.items():
            if col.shape != codes.shape:
                raise ValueError(
                    f"column {name!r} length {col.shape} != particle_type length {codes.shape}"
                )
        if not np.all(np.isin(codes, (IAEA_PHOTON, IAEA_ELECTRON, IAEA_POSITRON))):
            raise ValueError("particle_type must use the supported IAEA codes 1/2/3")
        if not np.all(arrays["energy"] > 0.0):
            raise ValueError("every particle needs a positive energy")
        norms = arrays["ux"] ** 2 + arrays["uy"] ** 2 + arrays["uz"] ** 2
        if not np.all(np.abs(norms - 1.0) < 1.0e-5):
            raise ValueError("directions must be unit vectors")
        if not np.all(np.isfinite(arrays["weight"])) or np.any(arrays["weight"] < 0.0):
            raise ValueError("weights must be finite and non-negative")
        self._codes = codes
        self._columns = arrays
        self._tripwire = _LatentVarianceTripwire(int(codes.size))

    def __len__(self) -> int:
        """Return the number of stored particles available to sample."""
        return int(self._codes.size)

    def columns(self) -> dict[str, np.ndarray]:
        """Return a copy of the stored population as column arrays.

        ``particle_type`` (IAEA codes) plus the float64 columns as constructed —
        for introspection, diagnostics and serialization; the sampling routes
        (:meth:`emit`, :meth:`sample_batch`) remain the transport-facing API.
        """
        out: dict[str, np.ndarray] = {"particle_type": self._codes.copy()}
        for name, col in self._columns.items():
            out[name] = col.copy()
        return out

    @property
    def max_energy(self) -> float:
        """Highest stored particle energy in MeV (for table sizing)."""
        return float(self._columns["energy"].max())

    def emit(self, rng_state: RNGState) -> Primary:
        """Emit one stored particle, sampled uniformly; consumes one uniform."""
        self._tripwire.count_one()
        k = int(uniform(rng_state) * len(self))
        if k >= len(self):  # guard the uniform == 1.0 corner
            k = len(self) - 1
        c = self._columns
        return Primary(
            energy=float(c["energy"][k]),
            x=float(c["x"][k]),
            y=float(c["y"][k]),
            z=float(c["z"][k]),
            ux=float(c["ux"][k]),
            uy=float(c["uy"][k]),
            uz=float(c["uz"][k]),
            kind=_IAEA_KIND[int(self._codes[k])],
            weight=float(c["weight"][k]),
        )

    def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, np.ndarray]:
        """Vectorized upload columns for histories ``[offset, offset + n)``.

        A pure fancy-indexed gather of the stored columns at the chunk-invariant
        :func:`_sample_indices`; the same stream/independence caveats as the
        file-backed source apply.
        """
        self._tripwire.note(history_offset + n)
        k = _sample_indices(seed, history_offset, n, len(self))
        out: dict[str, np.ndarray] = {"particle_type": self._codes[k].copy()}
        for name, col in self._columns.items():
            out[name] = col[k].astype(np.float32)
        return out

max_energy property

max_energy: float

Highest stored particle energy in MeV (for table sizing).

columns

columns() -> dict[str, np.ndarray]

Return a copy of the stored population as column arrays.

particle_type (IAEA codes) plus the float64 columns as constructed — for introspection, diagnostics and serialization; the sampling routes (:meth:emit, :meth:sample_batch) remain the transport-facing API.

Source code in pyradmc/geometry/phasespace.py
def columns(self) -> dict[str, np.ndarray]:
    """Return a copy of the stored population as column arrays.

    ``particle_type`` (IAEA codes) plus the float64 columns as constructed —
    for introspection, diagnostics and serialization; the sampling routes
    (:meth:`emit`, :meth:`sample_batch`) remain the transport-facing API.
    """
    out: dict[str, np.ndarray] = {"particle_type": self._codes.copy()}
    for name, col in self._columns.items():
        out[name] = col.copy()
    return out

emit

emit(rng_state: RNGState) -> Primary

Emit one stored particle, sampled uniformly; consumes one uniform.

Source code in pyradmc/geometry/phasespace.py
def emit(self, rng_state: RNGState) -> Primary:
    """Emit one stored particle, sampled uniformly; consumes one uniform."""
    self._tripwire.count_one()
    k = int(uniform(rng_state) * len(self))
    if k >= len(self):  # guard the uniform == 1.0 corner
        k = len(self) - 1
    c = self._columns
    return Primary(
        energy=float(c["energy"][k]),
        x=float(c["x"][k]),
        y=float(c["y"][k]),
        z=float(c["z"][k]),
        ux=float(c["ux"][k]),
        uy=float(c["uy"][k]),
        uz=float(c["uz"][k]),
        kind=_IAEA_KIND[int(self._codes[k])],
        weight=float(c["weight"][k]),
    )

sample_batch

sample_batch(seed: int, history_offset: int, n: int) -> dict[str, np.ndarray]

Vectorized upload columns for histories [offset, offset + n).

A pure fancy-indexed gather of the stored columns at the chunk-invariant :func:_sample_indices; the same stream/independence caveats as the file-backed source apply.

Source code in pyradmc/geometry/phasespace.py
def sample_batch(self, seed: int, history_offset: int, n: int) -> dict[str, np.ndarray]:
    """Vectorized upload columns for histories ``[offset, offset + n)``.

    A pure fancy-indexed gather of the stored columns at the chunk-invariant
    :func:`_sample_indices`; the same stream/independence caveats as the
    file-backed source apply.
    """
    self._tripwire.note(history_offset + n)
    k = _sample_indices(seed, history_offset, n, len(self))
    out: dict[str, np.ndarray] = {"particle_type": self._codes[k].copy()}
    for name, col in self._columns.items():
        out[name] = col[k].astype(np.float32)
    return out

Spectra

pyradmc.geometry.spectrum.Spectrum

A histogram photon spectrum sampled by CDF inversion.

Parameters:

Name Type Description Default
edges Sequence[float] | NDArray[floating[Any]]

Bin edges in MeV, strictly increasing, first edge positive; n + 1 values. The last edge is :attr:max_energy, which sizes cross-section tables.

required
weights Sequence[float] | NDArray[floating[Any]]

Per-bin content (an integral over the bin, not a density); n non-negative values, at least one positive. Normalized internally, so only ratios matter.

required
convention str

"number" (default): weights are photons per bin — what CDF-inversion sampling natively consumes. "energy_fluence": weights are energy fluence per bin, converted to photon number by dividing by the bin midpoint energy. Stated approximation: the midpoint conversion and the uniform within-bin sampling below are exact only in the narrow-bin limit; supply number weights directly if the distinction matters at your bin width.

'number'
Source code in pyradmc/geometry/spectrum.py
class Spectrum:
    """A histogram photon spectrum sampled by CDF inversion.

    Parameters
    ----------
    edges
        Bin edges in MeV, strictly increasing, first edge positive; ``n + 1`` values.
        The last edge is :attr:`max_energy`, which sizes cross-section tables.
    weights
        Per-bin **content** (an integral over the bin, not a density); ``n``
        non-negative values, at least one positive. Normalized internally, so only
        ratios matter.
    convention
        ``"number"`` (default): weights are photons per bin — what CDF-inversion
        sampling natively consumes. ``"energy_fluence"``: weights are energy fluence
        per bin, converted to photon number by dividing by the bin **midpoint**
        energy. Stated approximation: the midpoint conversion and the uniform
        within-bin sampling below are exact only in the narrow-bin limit; supply
        number weights directly if the distinction matters at your bin width.
    """

    def __init__(
        self,
        edges: Sequence[float] | npt.NDArray[np.floating[Any]],
        weights: Sequence[float] | npt.NDArray[np.floating[Any]],
        convention: str = "number",
    ) -> None:
        e = np.asarray(edges, dtype=np.float64)
        w = np.asarray(weights, dtype=np.float64)
        if e.ndim != 1 or e.size < 2:
            raise ValueError(f"a spectrum needs at least two edges, got shape {e.shape}")
        if not np.all(np.isfinite(e)) or not np.all(np.isfinite(w)):
            raise ValueError("spectrum edges and weights must be finite")
        if e[0] <= 0.0:
            raise ValueError(f"the first edge must be positive, got {e[0]} MeV")
        if not np.all(np.diff(e) > 0.0):
            raise ValueError("spectrum edges must be strictly increasing")
        if w.shape != (e.size - 1,):
            raise ValueError(f"need one weight per bin: {e.size - 1} bins but {w.size} weights")
        if np.any(w < 0.0):
            raise ValueError("spectrum weights must be non-negative")
        if convention not in _CONVENTIONS:
            raise ValueError(f"unknown convention {convention!r}; expected {_CONVENTIONS}")
        if convention == "energy_fluence":
            w = w / (0.5 * (e[:-1] + e[1:]))
        total = float(w.sum())
        if total <= 0.0:
            raise ValueError("spectrum weights must include at least one positive value")

        self._edges = e
        self._p = w / total
        self._cdf = np.cumsum(self._p)
        self._edges.flags.writeable = False
        self._p.flags.writeable = False
        self._cdf.flags.writeable = False

    @property
    def max_energy(self) -> float:
        """Highest sampleable energy in MeV (the top bin edge)."""
        return float(self._edges[-1])

    @property
    def bin_probabilities(self) -> npt.NDArray[np.float64]:
        """Normalized per-bin photon emission probabilities (read-only)."""
        return self._p

    @property
    def edges(self) -> npt.NDArray[np.float64]:
        """Bin edges in MeV (read-only); ``n + 1`` values."""
        return self._edges

    @property
    def cdf(self) -> npt.NDArray[np.float64]:
        """Cumulative bin probabilities (read-only); ``n`` values ending at 1.

        The inversion table a device backend uploads to sample energies in-kernel
        with the same ``searchsorted`` convention as :meth:`sample_energy`.
        """
        return self._cdf

    @property
    def mean_energy(self) -> float:
        """Photon-number-weighted mean energy, MeV, on the bin-midpoint approximation."""
        return float(np.sum(self._p * 0.5 * (self._edges[:-1] + self._edges[1:])))

    def sample_energy(self, rng_state: RNGState) -> float:
        """Sample one photon energy by CDF inversion; consumes exactly two uniforms.

        The first uniform selects the bin from the cumulative weights (the
        :class:`~pyradmc.geometry.source.CompositeSource` selection idiom); the
        second places the energy uniformly within the bin.
        """
        k = min(int(np.searchsorted(self._cdf, uniform(rng_state))), self._p.size - 1)
        lo = float(self._edges[k])
        hi = float(self._edges[k + 1])
        return lo + (hi - lo) * uniform(rng_state)

    def sample_energies(
        self, u_bin: npt.NDArray[np.float64], u_within: npt.NDArray[np.float64]
    ) -> npt.NDArray[np.float64]:
        """Vectorized CDF inversion from caller-supplied uniforms.

        The same inversion as :meth:`sample_energy` — ``u_bin`` selects the bin,
        ``u_within`` the position inside it — for the vectorized pre-sampling batch
        of the spectral sources, which draws its uniforms from its own stream.
        """
        k = np.minimum(np.searchsorted(self._cdf, u_bin), self._p.size - 1)
        lo = self._edges[k]
        return np.asarray(lo + (self._edges[k + 1] - lo) * u_within, dtype=np.float64)

max_energy property

max_energy: float

Highest sampleable energy in MeV (the top bin edge).

bin_probabilities property

bin_probabilities: NDArray[float64]

Normalized per-bin photon emission probabilities (read-only).

edges property

edges: NDArray[float64]

Bin edges in MeV (read-only); n + 1 values.

cdf property

cdf: NDArray[float64]

Cumulative bin probabilities (read-only); n values ending at 1.

The inversion table a device backend uploads to sample energies in-kernel with the same searchsorted convention as :meth:sample_energy.

mean_energy property

mean_energy: float

Photon-number-weighted mean energy, MeV, on the bin-midpoint approximation.

sample_energy

sample_energy(rng_state: RNGState) -> float

Sample one photon energy by CDF inversion; consumes exactly two uniforms.

The first uniform selects the bin from the cumulative weights (the :class:~pyradmc.geometry.source.CompositeSource selection idiom); the second places the energy uniformly within the bin.

Source code in pyradmc/geometry/spectrum.py
def sample_energy(self, rng_state: RNGState) -> float:
    """Sample one photon energy by CDF inversion; consumes exactly two uniforms.

    The first uniform selects the bin from the cumulative weights (the
    :class:`~pyradmc.geometry.source.CompositeSource` selection idiom); the
    second places the energy uniformly within the bin.
    """
    k = min(int(np.searchsorted(self._cdf, uniform(rng_state))), self._p.size - 1)
    lo = float(self._edges[k])
    hi = float(self._edges[k + 1])
    return lo + (hi - lo) * uniform(rng_state)

sample_energies

sample_energies(u_bin: NDArray[float64], u_within: NDArray[float64]) -> npt.NDArray[np.float64]

Vectorized CDF inversion from caller-supplied uniforms.

The same inversion as :meth:sample_energyu_bin selects the bin, u_within the position inside it — for the vectorized pre-sampling batch of the spectral sources, which draws its uniforms from its own stream.

Source code in pyradmc/geometry/spectrum.py
def sample_energies(
    self, u_bin: npt.NDArray[np.float64], u_within: npt.NDArray[np.float64]
) -> npt.NDArray[np.float64]:
    """Vectorized CDF inversion from caller-supplied uniforms.

    The same inversion as :meth:`sample_energy` — ``u_bin`` selects the bin,
    ``u_within`` the position inside it — for the vectorized pre-sampling batch
    of the spectral sources, which draws its uniforms from its own stream.
    """
    k = np.minimum(np.searchsorted(self._cdf, u_bin), self._p.size - 1)
    lo = self._edges[k]
    return np.asarray(lo + (self._edges[k + 1] - lo) * u_within, dtype=np.float64)

pyradmc.geometry.spectrum.ali_rogers_mv

ali_rogers_mv(beam: str | AliRogersMV, e_min: float = 0.15, n_bins: int = 100) -> Spectrum

Build a Spectrum from the Ali and Rogers (2012) analytic MV form.

The governing equation is in :func:_psi_continuum; per-bin photon numbers are the sub-grid integrals of psi(E) / E (the product is integrated, not bin means — the AGENTS.md 2.7 rule), and a c4 > 0 adds the 511 keV annihilation line inside the common filtration envelope of function 13. Its photon content is c4 * exp(-mu_W C1^2 - mu_Al C2^2) / 0.511 at 511 keV.

Parameters:

Name Type Description Default
beam str | AliRogersMV

A key of :data:ALI_ROGERS_BEAMS (e.g. "varian-6mv") or explicit :class:AliRogersMV parameters.

required
e_min float

Lower spectrum edge in MeV. Must stay at or above the 69.5 keV validity floor of the tungsten attenuation parameterization; the default 0.15 MeV is far above PCUT and cuts only a negligible fluence tail.

0.15
n_bins int

Histogram resolution; the default matches the paper's 100-bin spectra.

100
Source code in pyradmc/geometry/spectrum.py
def ali_rogers_mv(beam: str | AliRogersMV, e_min: float = 0.15, n_bins: int = 100) -> Spectrum:
    """Build a Spectrum from the Ali and Rogers (2012) analytic MV form.

    The governing equation is in :func:`_psi_continuum`; per-bin photon numbers are
    the sub-grid integrals of ``psi(E) / E`` (the product is integrated, not bin
    means — the AGENTS.md 2.7 rule), and a ``c4 > 0`` adds the 511 keV annihilation
    line inside the common filtration envelope of function 13. Its photon content is
    ``c4 * exp(-mu_W C1^2 - mu_Al C2^2) / 0.511`` at 511 keV.

    Parameters
    ----------
    beam
        A key of :data:`ALI_ROGERS_BEAMS` (e.g. ``"varian-6mv"``) or explicit
        :class:`AliRogersMV` parameters.
    e_min
        Lower spectrum edge in MeV. Must stay at or above the 69.5 keV validity
        floor of the tungsten attenuation parameterization; the default 0.15 MeV
        is far above ``PCUT`` and cuts only a negligible fluence tail.
    n_bins
        Histogram resolution; the default matches the paper's 100-bin spectra.
    """
    if isinstance(beam, str):
        if beam not in ALI_ROGERS_BEAMS:
            raise KeyError(
                f"unknown beam {beam!r}; available: {', '.join(sorted(ALI_ROGERS_BEAMS))}"
            )
        beam = ALI_ROGERS_BEAMS[beam]
    if e_min < _MU_W_FLOOR_MEV:
        raise ValueError(
            f"e_min={e_min} MeV is below the 69.5 keV validity floor of the "
            "tungsten attenuation parameterization (Ali and Rogers 2012, table 3)"
        )
    if e_min >= beam.e_e:
        raise ValueError(f"e_min={e_min} MeV is not below the endpoint {beam.e_e} MeV")
    if n_bins < 1:
        raise ValueError(f"need at least one bin, got {n_bins}")

    edges = np.linspace(e_min, beam.e_e, n_bins + 1)
    # Per-bin photon number: integrate psi(E)/E on a sub-grid of each bin (trapezoid,
    # 8 panels per bin). The endpoint bin's upper limit is Ee itself, where the form
    # is finite (psi(Ee) ~ 0), so no special casing is needed.
    sub = 8
    fine = np.linspace(edges[:-1], edges[1:], sub + 1, axis=1)
    values = _psi_continuum(fine.reshape(-1), beam).reshape(fine.shape) / fine
    weights = np.asarray(np.trapezoid(values, fine, axis=1), dtype=np.float64)
    if beam.c4 > 0.0:
        if not e_min <= _E_ANNIHILATION_MEV < beam.e_e:
            raise ValueError(
                f"c4={beam.c4} books a 511 keV line outside the spectrum range "
                f"[{e_min}, {beam.e_e}) MeV"
            )
        k = int(np.searchsorted(edges, _E_ANNIHILATION_MEV, side="right") - 1)
        line_energy = np.array([_E_ANNIHILATION_MEV], dtype=np.float64)
        line_transmission = float(
            np.exp(
                -_mu_over_rho_tungsten(line_energy)[0] * beam.c1**2
                - _mu_over_rho_aluminium(line_energy)[0] * beam.c2**2
            )
        )
        weights[k] += beam.c4 * line_transmission / _E_ANNIHILATION_MEV
    return Spectrum(edges, weights)

Primary fluence

pyradmc.geometry.fluence.RadialFluence

A radially symmetric primary fluence psi(r), linearly interpolated.

Parameters:

Name Type Description Default
radii Sequence[float] | NDArray[floating[Any]]

Off-axis radii in cm, non-negative and strictly increasing; at least two values. They are distances at :paramref:reference_distance, not at the plane the source emits on — the source projects between the two.

required
values Sequence[float] | NDArray[floating[Any]]

Relative fluence at each radius; one per radius, non-negative, at least one positive. Used as given (see the module docstring on normalization).

required
reference_distance float

Distance from the focal spot, in cm, at which radii are quoted. The default 100 cm is the isocentre convention of a commissioning curve.

100.0
Source code in pyradmc/geometry/fluence.py
class RadialFluence:
    """A radially symmetric primary fluence psi(r), linearly interpolated.

    Parameters
    ----------
    radii
        Off-axis radii in **cm**, non-negative and strictly increasing; at least
        two values. They are distances *at* :paramref:`reference_distance`, not
        at the plane the source emits on — the source projects between the two.
    values
        Relative fluence at each radius; one per radius, non-negative, at least
        one positive. Used as given (see the module docstring on normalization).
    reference_distance
        Distance from the focal spot, in cm, at which ``radii`` are quoted. The
        default 100 cm is the isocentre convention of a commissioning curve.
    """

    def __init__(
        self,
        radii: Sequence[float] | npt.NDArray[np.floating[Any]],
        values: Sequence[float] | npt.NDArray[np.floating[Any]],
        reference_distance: float = 100.0,
    ) -> None:
        r = np.asarray(radii, dtype=np.float64)
        v = np.asarray(values, dtype=np.float64)
        if r.ndim != 1 or r.size < 2:
            raise ValueError(f"a fluence table needs at least two radii, got shape {r.shape}")
        if v.shape != r.shape:
            raise ValueError(f"need one value per radius: {r.size} radii but {v.size} values")
        if not np.all(np.isfinite(r)) or not np.all(np.isfinite(v)):
            raise ValueError("fluence radii and values must be finite")
        if r[0] < 0.0:
            raise ValueError(f"fluence radii must be non-negative, got {r[0]} cm")
        if not np.all(np.diff(r) > 0.0):
            raise ValueError("fluence radii must be strictly increasing")
        if np.any(v < 0.0):
            raise ValueError("fluence values must be non-negative")
        if not np.any(v > 0.0):
            raise ValueError("fluence values must include at least one positive value")
        if reference_distance <= 0.0:
            raise ValueError(f"reference_distance must be positive, got {reference_distance} cm")

        self._radii = r
        self._values = v
        self._reference_distance = float(reference_distance)
        self._radii.flags.writeable = False
        self._values.flags.writeable = False

    @property
    def radii(self) -> npt.NDArray[np.float64]:
        """Tabulated off-axis radii in cm at :attr:`reference_distance` (read-only)."""
        return self._radii

    @property
    def values(self) -> npt.NDArray[np.float64]:
        """Tabulated relative fluence, one per radius (read-only)."""
        return self._values

    @property
    def reference_distance(self) -> float:
        """Distance from the focal spot, cm, at which :attr:`radii` are quoted."""
        return self._reference_distance

    @property
    def max_radius(self) -> float:
        """Largest tabulated radius in cm; beyond it the fluence reads zero."""
        return float(self._radii[-1])

    def at_radius(self, radius: float) -> float:
        """Interpolate psi at one radius (cm at :attr:`reference_distance`).

        The magnitude is taken, so a signed off-axis coordinate reads correctly.
        See the module docstring for the two extrapolation rules.
        """
        return float(
            np.interp(abs(radius), self._radii, self._values, left=self._values[0], right=0.0)
        )

    def at_radii(self, radii: npt.NDArray[np.floating[Any]]) -> npt.NDArray[np.float64]:
        """Vectorized :meth:`at_radius`, for the pre-sampling batch route."""
        return np.asarray(
            np.interp(np.abs(radii), self._radii, self._values, left=self._values[0], right=0.0),
            dtype=np.float64,
        )

    @classmethod
    def from_file(
        cls,
        path: str | os.PathLike[str],
        radius_scale: float = 0.1,
        reference_distance: float = 100.0,
    ) -> RadialFluence:
        """Read a two-column whitespace table of ``radius fluence`` (``#`` comments).

        This is the layout of the PPBKC ``primflu.dat`` commissioning file, whose
        radii are in **mm** at isocentre; ``radius_scale`` converts them to the
        engine's cm (pass 1.0 for a table already in cm).
        """
        table = np.loadtxt(path, comments="#", ndmin=2)
        if table.shape[1] < 2:
            raise ValueError(f"{path}: expected two columns of 'radius fluence'")
        return cls(
            radii=table[:, 0] * radius_scale,
            values=table[:, 1],
            reference_distance=reference_distance,
        )

radii property

radii: NDArray[float64]

Tabulated off-axis radii in cm at :attr:reference_distance (read-only).

values property

values: NDArray[float64]

Tabulated relative fluence, one per radius (read-only).

reference_distance property

reference_distance: float

Distance from the focal spot, cm, at which :attr:radii are quoted.

max_radius property

max_radius: float

Largest tabulated radius in cm; beyond it the fluence reads zero.

at_radius

at_radius(radius: float) -> float

Interpolate psi at one radius (cm at :attr:reference_distance).

The magnitude is taken, so a signed off-axis coordinate reads correctly. See the module docstring for the two extrapolation rules.

Source code in pyradmc/geometry/fluence.py
def at_radius(self, radius: float) -> float:
    """Interpolate psi at one radius (cm at :attr:`reference_distance`).

    The magnitude is taken, so a signed off-axis coordinate reads correctly.
    See the module docstring for the two extrapolation rules.
    """
    return float(
        np.interp(abs(radius), self._radii, self._values, left=self._values[0], right=0.0)
    )

at_radii

at_radii(radii: NDArray[floating[Any]]) -> npt.NDArray[np.float64]

Vectorized :meth:at_radius, for the pre-sampling batch route.

Source code in pyradmc/geometry/fluence.py
def at_radii(self, radii: npt.NDArray[np.floating[Any]]) -> npt.NDArray[np.float64]:
    """Vectorized :meth:`at_radius`, for the pre-sampling batch route."""
    return np.asarray(
        np.interp(np.abs(radii), self._radii, self._values, left=self._values[0], right=0.0),
        dtype=np.float64,
    )

from_file classmethod

from_file(path: str | PathLike[str], radius_scale: float = 0.1, reference_distance: float = 100.0) -> RadialFluence

Read a two-column whitespace table of radius fluence (# comments).

This is the layout of the PPBKC primflu.dat commissioning file, whose radii are in mm at isocentre; radius_scale converts them to the engine's cm (pass 1.0 for a table already in cm).

Source code in pyradmc/geometry/fluence.py
@classmethod
def from_file(
    cls,
    path: str | os.PathLike[str],
    radius_scale: float = 0.1,
    reference_distance: float = 100.0,
) -> RadialFluence:
    """Read a two-column whitespace table of ``radius fluence`` (``#`` comments).

    This is the layout of the PPBKC ``primflu.dat`` commissioning file, whose
    radii are in **mm** at isocentre; ``radius_scale`` converts them to the
    engine's cm (pass 1.0 for a table already in cm).
    """
    table = np.loadtxt(path, comments="#", ndmin=2)
    if table.shape[1] < 2:
        raise ValueError(f"{path}: expected two columns of 'radius fluence'")
    return cls(
        radii=table[:, 0] * radius_scale,
        values=table[:, 1],
        reference_distance=reference_distance,
    )

Random numbers

pyradmc.rng.host.HostRNG

Bases: RNG

Counter-based NumPy RNG; see the module docstring.

Source code in pyradmc/rng/host.py
class HostRNG(RNG):
    """Counter-based NumPy RNG; see the module docstring."""

    def init_state(self, seed: int, history_index: int) -> np.random.Generator:
        """Create the generator for one history as a pure function of the arguments.

        ``SeedSequence(seed, spawn_key=(history_index,))`` hashes both integers into
        the PCG64 state, so histories neither share nor overlap streams regardless of
        how they are partitioned into batches.
        """
        seed_seq = np.random.SeedSequence(entropy=seed, spawn_key=(history_index,))
        return np.random.Generator(np.random.PCG64(seed_seq))

    def uniform(self, state: np.random.Generator) -> float:
        """Draw from U[0, 1); delegates to the module-level :func:`uniform`."""
        return uniform(state)

init_state

init_state(seed: int, history_index: int) -> np.random.Generator

Create the generator for one history as a pure function of the arguments.

SeedSequence(seed, spawn_key=(history_index,)) hashes both integers into the PCG64 state, so histories neither share nor overlap streams regardless of how they are partitioned into batches.

Source code in pyradmc/rng/host.py
def init_state(self, seed: int, history_index: int) -> np.random.Generator:
    """Create the generator for one history as a pure function of the arguments.

    ``SeedSequence(seed, spawn_key=(history_index,))`` hashes both integers into
    the PCG64 state, so histories neither share nor overlap streams regardless of
    how they are partitioned into batches.
    """
    seed_seq = np.random.SeedSequence(entropy=seed, spawn_key=(history_index,))
    return np.random.Generator(np.random.PCG64(seed_seq))

uniform

uniform(state: Generator) -> float

Draw from U[0, 1); delegates to the module-level :func:uniform.

Source code in pyradmc/rng/host.py
def uniform(self, state: np.random.Generator) -> float:
    """Draw from U[0, 1); delegates to the module-level :func:`uniform`."""
    return uniform(state)

Accuracy-defining constants

These live in one place so that a change to any of them is visible in a diff. They are not tuning knobs: changing one requires a test demonstrating the dosimetric effect.

pyradmc: fast photon Monte Carlo for beamlet-resolved treatment planning.

See AGENTS.md for the development contract.

This module is the public API: everything named in __all__ is supported and versioned, and everything else is an implementation detail that may move between releases. Physical constants and defaults that are accuracy-defining live here too, so that there is exactly one place to change them and so that a change is visible in a diff. They are not tuning knobs; see AGENTS.md section 2.8.

ECUT_MEV module-attribute

ECUT_MEV: float = 0.2

Electron transport and production cutoff, kinetic energy in MeV (DPM default).

PCUT_MEV module-attribute

PCUT_MEV: float = 0.05

Photon transport cutoff in MeV. Below this, energy is deposited locally.

DIJ_TRUNCATION_RELATIVE module-attribute

DIJ_TRUNCATION_RELATIVE: float = 0.001

Dij column truncation, relative to that beamlet column's maximum.

This biases the low-dose tail, which is where NTCP and LET-guided objectives operate. It is tested against DVH endpoints, never against a matrix norm.

PHOTON_ROULETTE_MEV module-attribute

PHOTON_ROULETTE_MEV: float = 0.5

Photons below this energy play Russian roulette at their creation or scatter.

Chosen just below the 511 keV annihilation line so annihilation photons are exempt and positron energy accounting stays analog.

PHOTON_ROULETTE_SURVIVAL module-attribute

PHOTON_ROULETTE_SURVIVAL: float = 0.5

Survival probability per game; a survivor's weight is boosted by its inverse.

PHOTON_ROULETTE_WEIGHT_CAP module-attribute

PHOTON_ROULETTE_WEIGHT_CAP: float = 4.0

No roulette at or above this weight (the weight-window ceiling).

Caps the boost cascade at two consecutive survivals (1 -> 2 -> 4), bounding the graininess a single high-weight deposit can leave in the low-dose tail.

PHOTON_SPLIT_N module-attribute

PHOTON_SPLIT_N: int = 1

Compton splitting multiplicity at a primary photon's first Compton scatter.

N = 1 is splitting off — the shipped configuration. At N == 1 the primary Comptons into a single full-weight copy, i.e. exactly analog transport. For N > 1 the primary's Compton final state is sampled N times, each copy (scattered photon + recoil electron) carrying weight 1 / N: N independent samples of the dominant scatter source, exactly unbiased and energy-conserving per realization, with cost growing about linearly in N. Only the primary splits, so the population is bounded and the soft-photon roulette culls the degraded copies.

This is a variance-reduction efficiency knob, not accuracy-defining: it changes realizations and cost, never expectations. Correctness of the N > 1 path (unbiasedness, energy books, N-fold fair copies, variance reduction) is test-pinned with N = 2 as the instrument, so the mechanism stays validated though it is dormant.

Why it ships off (measured). For the analytic-water Dij, splitting does not earn its keep: the figure of merit 1/(sigma^2*time) is < 1 on the reference CPU (variance falls to ~0.67 in the high/mid-dose region but cost rises ~1.7x) and roughly neutral on the GPU (a warp retires with its longest thread). Worse, it does not help the low-dose tail — the Dij's NTCP/LET region — because that tail is fed by rare wide-angle multiple scatters that uniform primary splitting cannot target; splitting deeper only degrades the FOM further (measured).

Re-measured on a phase-space source, it still ships off. On now-stable-power hardware the FOM ratio split/no-split was 0.75, 0.48, 0.28 at N = 2, 4, 8 — worse, monotonically. First-Compton splitting decorrelates copies only after that scatter (variance saturates far below 1/N) while cost grows ~linearly, and emitting a phase-space primary is as cheap as an analytic beam, so the cost structure matches. The N > 1 path stays retained and N=2-pinned. See docs/decisions.md for the full record and the emission-time-splitting alternative.

ELECTRON_MASS_MEV module-attribute

ELECTRON_MASS_MEV: float = 0.51099895069

Electron rest mass energy in MeV (CODATA 2022).

GY_PER_MEV_PER_G module-attribute

GY_PER_MEV_PER_G: float = 1.602176634e-10

Absolute-dose calibration: 1 MeV/g = this many gray.

Exact by SI definition: 1 MeV = e x 1e6 J with the elementary charge fixed at 1.602176634e-19 C (SI 2019), and per gram -> per kilogram is 1e3. The engines score dose in MeV/g per emitted history; a planning consumer multiplies by this constant for Gy per history and applies its own particles-per-MU scaling on top (see :meth:pyradmc.scoring.dij.DijResult.dose_csc).

RAYLEIGH_MOMENTUM_TRANSFER_PER_MEV module-attribute

RAYLEIGH_MOMENTUM_TRANSFER_PER_MEV: float = 80.65543

Coherent-scattering momentum-transfer coefficient: the tabulated form-factor abscissa is x [1/angstrom] = this * E[MeV] * sin(theta/2), i.e. 1/hc with hc = 0.012_398_42 MeV*angstrom (CODATA 2022). EPDL MF=27 tabulates F against x.

Subsystems documented at module level

These are coherent subsystems with their own vocabulary rather than names a first script reaches for. They are supported, but imported from their modules:

Module What it provides
pyradmc.geometry.collimation Jaw pairs, rounded-tip MLC, collimated and transmission-mask source wrappers
pyradmc.geometry.head Treatment-head pre-solve producing an exit-plane phase space
pyradmc.adapters.ct Hounsfield calibration and CT image reading
pyradmc.data.tabulated Table precompiler and the EPICS build tool
pyradmc.study Toy fluence optimizer and DVH endpoints, used by the noise/bias study