pyRadMC 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. pyradmc/__init__.py +308 -0
  2. pyradmc/adapters/__init__.py +9 -0
  3. pyradmc/adapters/ct.py +244 -0
  4. pyradmc/backends/__init__.py +1 -0
  5. pyradmc/backends/ref/__init__.py +5 -0
  6. pyradmc/backends/ref/engine.py +463 -0
  7. pyradmc/backends/results.py +115 -0
  8. pyradmc/backends/warp/__init__.py +1 -0
  9. pyradmc/backends/warp/engine.py +3451 -0
  10. pyradmc/backends/warp/kernels.py +2401 -0
  11. pyradmc/backends/warp/physics.py +211 -0
  12. pyradmc/backends/warp/presolve.py +742 -0
  13. pyradmc/data/__init__.py +1 -0
  14. pyradmc/data/analytic.py +321 -0
  15. pyradmc/data/berger_seltzer.py +240 -0
  16. pyradmc/data/goudsmit_saunderson.py +1122 -0
  17. pyradmc/data/handles.py +34 -0
  18. pyradmc/data/interface.py +437 -0
  19. pyradmc/data/materials.py +345 -0
  20. pyradmc/data/tables.py +362 -0
  21. pyradmc/data/tabulated/__init__.py +10 -0
  22. pyradmc/data/tabulated/build.py +211 -0
  23. pyradmc/data/tabulated/eedl.py +315 -0
  24. pyradmc/data/tabulated/endf.py +258 -0
  25. pyradmc/data/tabulated/epdl.py +234 -0
  26. pyradmc/data/tabulated/format.py +102 -0
  27. pyradmc/data/tabulated/model.py +48 -0
  28. pyradmc/data/tabulated/precompile.py +267 -0
  29. pyradmc/data/tabulated/source.py +241 -0
  30. pyradmc/geometry/__init__.py +1 -0
  31. pyradmc/geometry/collimation.py +1284 -0
  32. pyradmc/geometry/cylinder.py +118 -0
  33. pyradmc/geometry/fluence.py +147 -0
  34. pyradmc/geometry/grid.py +337 -0
  35. pyradmc/geometry/head.py +599 -0
  36. pyradmc/geometry/phasespace.py +769 -0
  37. pyradmc/geometry/source.py +1195 -0
  38. pyradmc/geometry/spectrum.py +310 -0
  39. pyradmc/physics/__init__.py +1 -0
  40. pyradmc/physics/brems.py +83 -0
  41. pyradmc/physics/channel.py +55 -0
  42. pyradmc/physics/compton.py +118 -0
  43. pyradmc/physics/direction.py +81 -0
  44. pyradmc/physics/gs.py +166 -0
  45. pyradmc/physics/moller.py +87 -0
  46. pyradmc/physics/msc.py +54 -0
  47. pyradmc/physics/path.py +35 -0
  48. pyradmc/physics/rayleigh.py +118 -0
  49. pyradmc/physics/roulette.py +34 -0
  50. pyradmc/progress.py +96 -0
  51. pyradmc/py.typed +0 -0
  52. pyradmc/rng/__init__.py +12 -0
  53. pyradmc/rng/host.py +48 -0
  54. pyradmc/rng/interface.py +64 -0
  55. pyradmc/rng/warp_shim.py +81 -0
  56. pyradmc/scoring/__init__.py +1 -0
  57. pyradmc/scoring/cylinder.py +513 -0
  58. pyradmc/scoring/dij.py +434 -0
  59. pyradmc/scoring/dose.py +207 -0
  60. pyradmc/scoring/dose_to_water.py +84 -0
  61. pyradmc/scoring/grid.py +219 -0
  62. pyradmc/study.py +150 -0
  63. pyradmc/transport/__init__.py +1 -0
  64. pyradmc/transport/electron.py +481 -0
  65. pyradmc/transport/history.py +150 -0
  66. pyradmc/transport/particles.py +102 -0
  67. pyradmc/transport/photon.py +348 -0
  68. pyradmc-0.2.0.dist-info/METADATA +162 -0
  69. pyradmc-0.2.0.dist-info/RECORD +72 -0
  70. pyradmc-0.2.0.dist-info/WHEEL +4 -0
  71. pyradmc-0.2.0.dist-info/licenses/LICENSE +201 -0
  72. pyradmc-0.2.0.dist-info/licenses/NOTICE +60 -0
pyradmc/__init__.py ADDED
@@ -0,0 +1,308 @@
1
+ """pyradmc: fast photon Monte Carlo for beamlet-resolved treatment planning.
2
+
3
+ See AGENTS.md for the development contract.
4
+
5
+ This module is the public API: everything named in ``__all__`` is supported and
6
+ versioned, and everything else is an implementation detail that may move between
7
+ releases. Physical constants and defaults that are accuracy-defining live here too,
8
+ so that there is exactly one place to change them and so that a change is visible in
9
+ a diff. They are not tuning knobs; see AGENTS.md section 2.8.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ # --- accuracy-defining defaults ------------------------------------------------
20
+ # Changing any of these requires a test demonstrating the dosimetric effect.
21
+
22
+ ECUT_MEV: float = 0.200
23
+ """Electron transport and production cutoff, kinetic energy in MeV (DPM default)."""
24
+
25
+ PCUT_MEV: float = 0.050
26
+ """Photon transport cutoff in MeV. Below this, energy is deposited locally."""
27
+
28
+ DIJ_TRUNCATION_RELATIVE: float = 1.0e-3
29
+ """Dij column truncation, relative to that beamlet column's maximum.
30
+
31
+ This biases the low-dose tail, which is where NTCP and LET-guided objectives operate.
32
+ It is tested against DVH endpoints, never against a matrix norm.
33
+ """
34
+
35
+ # --- variance-reduction parameters -------------------------------------
36
+ # Russian roulette on low-energy photons (see pyradmc.physics.roulette and the
37
+ # transport loops). Unbiased by construction — these change realizations and
38
+ # efficiency, never expectations; unbiasedness is test-pinned against a
39
+ # roulette-free run. They are still fixed project-wide values, not per-run options
40
+ # (AGENTS.md 2.10): one configuration is what the validation tier certifies.
41
+
42
+ PHOTON_ROULETTE_MEV: float = 0.5
43
+ """Photons below this energy play Russian roulette at their creation or scatter.
44
+
45
+ Chosen just below the 511 keV annihilation line so annihilation photons are exempt
46
+ and positron energy accounting stays analog.
47
+ """
48
+
49
+ PHOTON_ROULETTE_SURVIVAL: float = 0.5
50
+ """Survival probability per game; a survivor's weight is boosted by its inverse."""
51
+
52
+ PHOTON_ROULETTE_WEIGHT_CAP: float = 4.0
53
+ """No roulette at or above this weight (the weight-window ceiling).
54
+
55
+ Caps the boost cascade at two consecutive survivals (1 -> 2 -> 4), bounding the
56
+ graininess a single high-weight deposit can leave in the low-dose tail.
57
+ """
58
+
59
+ PHOTON_SPLIT_N: int = 1
60
+ """Compton splitting multiplicity at a *primary* photon's first Compton scatter.
61
+
62
+ ``N = 1`` is **splitting off — the shipped configuration.** At N == 1 the
63
+ primary Comptons into a single full-weight copy, i.e. exactly analog transport.
64
+ For ``N > 1`` the primary's Compton final state is sampled N times, each copy
65
+ (scattered photon + recoil electron) carrying weight ``1 / N``: N independent
66
+ samples of the dominant scatter source, exactly unbiased and energy-conserving
67
+ per realization, with cost growing about linearly in N. Only the primary
68
+ splits, so the population is bounded and the soft-photon roulette culls the
69
+ degraded copies.
70
+
71
+ This is a variance-reduction efficiency knob, not accuracy-defining: it changes
72
+ realizations and cost, never expectations. Correctness of the ``N > 1`` path
73
+ (unbiasedness, energy books, N-fold fair copies, variance reduction) is
74
+ test-pinned with N = 2 as the instrument, so the mechanism stays validated
75
+ though it is dormant.
76
+
77
+ **Why it ships off (measured).** For the analytic-water
78
+ Dij, splitting does not earn its keep: the figure of merit ``1/(sigma^2*time)``
79
+ is < 1 on the reference CPU (variance falls to ~0.67 in the high/mid-dose
80
+ region but cost rises ~1.7x) and roughly neutral on the GPU (a warp retires
81
+ with its longest thread). Worse, it does **not** help the low-dose tail — the
82
+ Dij's NTCP/LET region — because that tail is fed by rare wide-angle multiple
83
+ scatters that uniform primary splitting cannot target; splitting deeper only
84
+ degrades the FOM further (measured).
85
+
86
+ **Re-measured on a phase-space source, it still ships off.** On
87
+ now-stable-power hardware the FOM ratio split/no-split was 0.75, 0.48, 0.28 at
88
+ N = 2, 4, 8 — worse, monotonically. First-Compton splitting decorrelates copies
89
+ only after that scatter (variance saturates far below 1/N) while cost grows
90
+ ~linearly, and emitting a phase-space primary is as cheap as an analytic beam,
91
+ so the cost structure matches. The N > 1 path stays retained and N=2-pinned. See
92
+ docs/decisions.md for the full record and the emission-time-splitting alternative.
93
+ """
94
+
95
+ # --- physical constants --------------------------------------------------------
96
+
97
+ ELECTRON_MASS_MEV: float = 0.510_998_950_69
98
+ """Electron rest mass energy in MeV (CODATA 2022)."""
99
+
100
+ GY_PER_MEV_PER_G: float = 1.602_176_634e-10
101
+ """Absolute-dose calibration: 1 MeV/g = this many gray.
102
+
103
+ Exact by SI definition: 1 MeV = e x 1e6 J with the elementary charge fixed at
104
+ 1.602176634e-19 C (SI 2019), and per gram -> per kilogram is 1e3. The engines
105
+ score dose in MeV/g per emitted history; a planning consumer multiplies by this
106
+ constant for Gy per history and applies its own particles-per-MU scaling on top
107
+ (see :meth:`pyradmc.scoring.dij.DijResult.dose_csc`)."""
108
+
109
+ RAYLEIGH_MOMENTUM_TRANSFER_PER_MEV: float = 80.65543
110
+ """Coherent-scattering momentum-transfer coefficient: the tabulated form-factor abscissa
111
+ is ``x [1/angstrom] = this * E[MeV] * sin(theta/2)``, i.e. ``1/hc`` with
112
+ ``hc = 0.012_398_42 MeV*angstrom`` (CODATA 2022). EPDL MF=27 tabulates ``F`` against ``x``."""
113
+
114
+ # --- public API ----------------------------------------------------------------
115
+ # Re-exported lazily (PEP 562). ``import pyradmc`` must stay cheap and dependency-free:
116
+ # a consumer that wants only the constants must not pay for NumPy, and a core-only
117
+ # install (no ``warp`` extra) must not fail at import merely because ``WarpEngine`` is
118
+ # a public name. The ``TYPE_CHECKING`` block below is what mypy and IDEs resolve
119
+ # against; ``_LAZY_EXPORTS`` is what actually runs.
120
+ #
121
+ # Names NOT promoted here are still importable from their modules, but they are not
122
+ # the public API: beam-limiting devices (``pyradmc.geometry.collimation``), the
123
+ # treatment-head pre-solve (``pyradmc.geometry.head``), the CT adapter
124
+ # (``pyradmc.adapters.ct``), the tabulated-table precompiler
125
+ # (``pyradmc.data.tabulated``) and the toy optimizer (``pyradmc.study``) are
126
+ # documented at subpackage level because each is a coherent subsystem with its own
127
+ # vocabulary, not a name a first script reaches for.
128
+
129
+ if TYPE_CHECKING:
130
+ from pyradmc.backends.ref.engine import ReferenceEngine
131
+ from pyradmc.backends.results import TransportResult
132
+ from pyradmc.backends.warp.engine import WarpEngine
133
+ from pyradmc.data.analytic import AnalyticCrossSections
134
+ from pyradmc.data.interface import CrossSectionSource, PhotonProcess
135
+ from pyradmc.data.materials import (
136
+ ADIPOSE,
137
+ AIR,
138
+ CORTICAL_BONE,
139
+ LUNG,
140
+ MATERIALS,
141
+ TUNGSTEN,
142
+ WATER,
143
+ MaterialData,
144
+ )
145
+ from pyradmc.data.tabulated.source import TabulatedCrossSections
146
+ from pyradmc.geometry.fluence import RadialFluence
147
+ from pyradmc.geometry.grid import VoxelGrid
148
+ from pyradmc.geometry.phasespace import InMemoryPhaseSpaceSource, PhaseSpaceSource
149
+ from pyradmc.geometry.source import (
150
+ BeamletGridSource,
151
+ BeamletSource,
152
+ CompositeBeamletSource,
153
+ CompositeSource,
154
+ GaussianSpotBeamletSource,
155
+ GaussianSpotBeamSource,
156
+ ParallelBeamSource,
157
+ PencilBeamSource,
158
+ Primary,
159
+ PrimaryFluenceBeamletSource,
160
+ PrimaryFluenceBeamSource,
161
+ Source,
162
+ SpectralBeamletSource,
163
+ SpectralBeamSource,
164
+ )
165
+ from pyradmc.geometry.spectrum import ALI_ROGERS_BEAMS, Spectrum, ali_rogers_mv
166
+ from pyradmc.rng.host import HostRNG
167
+ from pyradmc.scoring.cylinder import (
168
+ CylindricalScoringGrid,
169
+ geometric_edges,
170
+ graded_edges,
171
+ uniform_edges,
172
+ )
173
+ from pyradmc.scoring.dij import DijResult
174
+ from pyradmc.scoring.grid import ScoringGrid
175
+
176
+ _LAZY_EXPORTS: dict[str, str] = {
177
+ "ADIPOSE": "pyradmc.data.materials",
178
+ "AIR": "pyradmc.data.materials",
179
+ "ALI_ROGERS_BEAMS": "pyradmc.geometry.spectrum",
180
+ "AnalyticCrossSections": "pyradmc.data.analytic",
181
+ "BeamletGridSource": "pyradmc.geometry.source",
182
+ "BeamletSource": "pyradmc.geometry.source",
183
+ "CORTICAL_BONE": "pyradmc.data.materials",
184
+ "CompositeBeamletSource": "pyradmc.geometry.source",
185
+ "CompositeSource": "pyradmc.geometry.source",
186
+ "CrossSectionSource": "pyradmc.data.interface",
187
+ "CylindricalScoringGrid": "pyradmc.scoring.cylinder",
188
+ "DijResult": "pyradmc.scoring.dij",
189
+ "GaussianSpotBeamSource": "pyradmc.geometry.source",
190
+ "GaussianSpotBeamletSource": "pyradmc.geometry.source",
191
+ "HostRNG": "pyradmc.rng.host",
192
+ "InMemoryPhaseSpaceSource": "pyradmc.geometry.phasespace",
193
+ "LUNG": "pyradmc.data.materials",
194
+ "MATERIALS": "pyradmc.data.materials",
195
+ "MaterialData": "pyradmc.data.materials",
196
+ "ParallelBeamSource": "pyradmc.geometry.source",
197
+ "PencilBeamSource": "pyradmc.geometry.source",
198
+ "PhaseSpaceSource": "pyradmc.geometry.phasespace",
199
+ "PhotonProcess": "pyradmc.data.interface",
200
+ "Primary": "pyradmc.geometry.source",
201
+ "PrimaryFluenceBeamSource": "pyradmc.geometry.source",
202
+ "PrimaryFluenceBeamletSource": "pyradmc.geometry.source",
203
+ "RadialFluence": "pyradmc.geometry.fluence",
204
+ "ReferenceEngine": "pyradmc.backends.ref.engine",
205
+ "ScoringGrid": "pyradmc.scoring.grid",
206
+ "Source": "pyradmc.geometry.source",
207
+ "Spectrum": "pyradmc.geometry.spectrum",
208
+ "SpectralBeamSource": "pyradmc.geometry.source",
209
+ "SpectralBeamletSource": "pyradmc.geometry.source",
210
+ "TUNGSTEN": "pyradmc.data.materials",
211
+ "TabulatedCrossSections": "pyradmc.data.tabulated.source",
212
+ "TransportResult": "pyradmc.backends.results",
213
+ "VoxelGrid": "pyradmc.geometry.grid",
214
+ "WATER": "pyradmc.data.materials",
215
+ "WarpEngine": "pyradmc.backends.warp.engine",
216
+ "ali_rogers_mv": "pyradmc.geometry.spectrum",
217
+ "geometric_edges": "pyradmc.scoring.cylinder",
218
+ "graded_edges": "pyradmc.scoring.cylinder",
219
+ "uniform_edges": "pyradmc.scoring.cylinder",
220
+ }
221
+
222
+ # Third-party module whose absence means an optional extra is not installed -> the
223
+ # extra that provides it. Only consulted when the import actually failed on that
224
+ # module, so a genuine ImportError from inside our own code still propagates as-is.
225
+ _EXTRA_FOR_MISSING_MODULE: dict[str, str] = {"warp": "warp", "SimpleITK": "ct"}
226
+
227
+
228
+ def __getattr__(name: str) -> Any:
229
+ """Resolve a public name on first access, then cache it in the module globals."""
230
+ module_name = _LAZY_EXPORTS.get(name)
231
+ if module_name is None:
232
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
233
+ try:
234
+ module = importlib.import_module(module_name)
235
+ except ImportError as exc:
236
+ missing = (getattr(exc, "name", "") or "").split(".")[0]
237
+ extra = _EXTRA_FOR_MISSING_MODULE.get(missing)
238
+ if extra is None:
239
+ raise
240
+ raise ImportError(
241
+ f"pyradmc.{name} needs the optional {extra!r} extra: pip install 'pyradmc[{extra}]'"
242
+ ) from exc
243
+ value = getattr(module, name)
244
+ globals()[name] = value # later lookups find it directly and skip __getattr__
245
+ return value
246
+
247
+
248
+ def __dir__() -> list[str]:
249
+ """List the public API, so tab-completion sees names not yet imported."""
250
+ return sorted(__all__)
251
+
252
+
253
+ __all__ = [
254
+ "ADIPOSE",
255
+ "AIR",
256
+ "ALI_ROGERS_BEAMS",
257
+ "CORTICAL_BONE",
258
+ "DIJ_TRUNCATION_RELATIVE",
259
+ "ECUT_MEV",
260
+ "ELECTRON_MASS_MEV",
261
+ "GY_PER_MEV_PER_G",
262
+ "LUNG",
263
+ "MATERIALS",
264
+ "PCUT_MEV",
265
+ "PHOTON_ROULETTE_MEV",
266
+ "PHOTON_ROULETTE_SURVIVAL",
267
+ "PHOTON_ROULETTE_WEIGHT_CAP",
268
+ "PHOTON_SPLIT_N",
269
+ "RAYLEIGH_MOMENTUM_TRANSFER_PER_MEV",
270
+ "TUNGSTEN",
271
+ "WATER",
272
+ "AnalyticCrossSections",
273
+ "BeamletGridSource",
274
+ "BeamletSource",
275
+ "CompositeBeamletSource",
276
+ "CompositeSource",
277
+ "CrossSectionSource",
278
+ "CylindricalScoringGrid",
279
+ "DijResult",
280
+ "GaussianSpotBeamSource",
281
+ "GaussianSpotBeamletSource",
282
+ "HostRNG",
283
+ "InMemoryPhaseSpaceSource",
284
+ "MaterialData",
285
+ "ParallelBeamSource",
286
+ "PencilBeamSource",
287
+ "PhaseSpaceSource",
288
+ "PhotonProcess",
289
+ "Primary",
290
+ "PrimaryFluenceBeamSource",
291
+ "PrimaryFluenceBeamletSource",
292
+ "RadialFluence",
293
+ "ReferenceEngine",
294
+ "ScoringGrid",
295
+ "Source",
296
+ "SpectralBeamSource",
297
+ "SpectralBeamletSource",
298
+ "Spectrum",
299
+ "TabulatedCrossSections",
300
+ "TransportResult",
301
+ "VoxelGrid",
302
+ "WarpEngine",
303
+ "__version__",
304
+ "ali_rogers_mv",
305
+ "geometric_edges",
306
+ "graded_edges",
307
+ "uniform_edges",
308
+ ]
@@ -0,0 +1,9 @@
1
+ """Optional adapters that bridge external inputs to the engine's core types.
2
+
3
+ Adapters live outside the core (AGENTS.md 6): each may carry its own optional
4
+ dependency, declared as an extra in ``pyproject.toml``, and the core never imports one.
5
+ The CT adapter (:mod:`pyradmc.adapters.ct`) turns a patient CT into a
6
+ :class:`~pyradmc.geometry.grid.VoxelGrid`; the pyRadPlan adapter is planned to follow.
7
+ """
8
+
9
+ from __future__ import annotations
pyradmc/adapters/ct.py ADDED
@@ -0,0 +1,244 @@
1
+ """CT image -> :class:`~pyradmc.geometry.grid.VoxelGrid` adapter.
2
+
3
+ Turns a patient CT into the engine's geometry: a Hounsfield-unit calibration maps each
4
+ voxel's CT number to a mass density (a piecewise-linear ramp) and to a registry
5
+ material index (HU threshold bins into the ICRP media the materials task added), and
6
+ :func:`grid_from_hu` assembles the grid. :func:`read_ct` reads an image file through
7
+ SimpleITK; everything above it is pure NumPy and needs no optional dependency, so the
8
+ calibration is testable and reusable on its own.
9
+
10
+ Segmentation and density are deliberately independent, which is the whole point of the
11
+ multi-material design: the material index selects the elemental composition (the
12
+ compiled cross-section row) and the per-voxel density scales it. A voxel at -500 HU is
13
+ *lung tissue* (composition) at ~0.5 g/cm^3 (density), not a fixed reference-density
14
+ material.
15
+
16
+ The default calibration is illustrative, in Schneider's spirit (Schneider, Bortfeld &
17
+ Schlegel, Phys. Med. Biol. 45, 459 (2000), doi:10.1088/0031-9155/45/2/314; bilinear
18
+ density ramp after Schneider, Pedroni & Lomax, Phys. Med. Biol. 41, 111 (1996),
19
+ doi:10.1088/0031-9155/41/1/009). CT calibration is scanner-specific: a real plan passes
20
+ its own :class:`HounsfieldCalibration`, and these defaults are a runnable starting
21
+ point, not a clinical curve.
22
+
23
+ This adapter is out of the core (AGENTS.md 6); ``read_ct`` needs the optional
24
+ ``pyradmc[ct]`` extra (SimpleITK, Apache-2.0).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from typing import TYPE_CHECKING
31
+
32
+ import numpy as np
33
+ import numpy.typing as npt
34
+
35
+ from pyradmc.data.materials import ADIPOSE, AIR, CORTICAL_BONE, LUNG, MATERIALS, WATER
36
+ from pyradmc.geometry.grid import VoxelGrid
37
+
38
+ if TYPE_CHECKING:
39
+ import SimpleITK as sitk
40
+
41
+ __all__ = [
42
+ "DEFAULT_CALIBRATION",
43
+ "HounsfieldCalibration",
44
+ "grid_from_hu",
45
+ "grid_from_image",
46
+ "read_ct",
47
+ ]
48
+
49
+ _MM_PER_CM = 10.0
50
+
51
+
52
+ # Default HU -> mass density ramp (g/cm^3), linearly interpolated and clamped at the
53
+ # ends. Air and water are the fixed physical anchors; the bone segment reaches liquid
54
+ # water's cortical-bone reference density (~1.85 g/cm^3) near 1400 HU and continues to
55
+ # dense mineral above. Nodes ascending in HU.
56
+ _DEFAULT_DENSITY_HU: tuple[float, ...] = (-1000.0, 0.0, 1000.0, 3000.0)
57
+ _DEFAULT_DENSITY_VALUES: tuple[float, ...] = (0.00121, 1.000, 1.600, 2.800)
58
+
59
+ # Default HU -> material bins. n thresholds cut the HU axis into n+1 bins; bin i (HU in
60
+ # [thresholds[i-1], thresholds[i])) takes material_indices[i]. Illustrative Schneider-
61
+ # style cut points into the ICRP registry media.
62
+ _DEFAULT_MATERIAL_THRESHOLDS: tuple[float, ...] = (-950.0, -120.0, -20.0, 125.0)
63
+ _DEFAULT_MATERIAL_INDICES: tuple[int, ...] = (AIR, LUNG, ADIPOSE, WATER, CORTICAL_BONE)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class HounsfieldCalibration:
68
+ """A CT-number calibration: HU -> mass density and HU -> registry material.
69
+
70
+ Attributes
71
+ ----------
72
+ density_hu, density_values
73
+ The density ramp: HU control points (strictly ascending) and the mass density
74
+ in g/cm^3 at each. Interpolated linearly, clamped flat beyond the ends. All
75
+ densities must be positive — the grid rejects non-positive density.
76
+ material_thresholds
77
+ Ascending HU cut points. ``n`` thresholds define ``n + 1`` bins.
78
+ material_indices
79
+ The registry material index for each bin, low-HU to high-HU; length must be
80
+ ``len(material_thresholds) + 1``. Each must be a valid index into
81
+ :data:`pyradmc.data.materials.MATERIALS`.
82
+ """
83
+
84
+ density_hu: tuple[float, ...]
85
+ density_values: tuple[float, ...]
86
+ material_thresholds: tuple[float, ...]
87
+ material_indices: tuple[int, ...]
88
+ _density_hu_arr: npt.NDArray[np.float64] = field(init=False, repr=False, compare=False)
89
+ _density_val_arr: npt.NDArray[np.float64] = field(init=False, repr=False, compare=False)
90
+ _threshold_arr: npt.NDArray[np.float64] = field(init=False, repr=False, compare=False)
91
+ _index_arr: npt.NDArray[np.int32] = field(init=False, repr=False, compare=False)
92
+
93
+ def __post_init__(self) -> None:
94
+ """Validate the ramp and the bins, then cache their array forms."""
95
+ hu = np.asarray(self.density_hu, dtype=np.float64)
96
+ values = np.asarray(self.density_values, dtype=np.float64)
97
+ if hu.shape != values.shape or hu.size < 2:
98
+ raise ValueError("density_hu and density_values must be equal-length (>=2)")
99
+ if not np.all(np.diff(hu) > 0.0):
100
+ raise ValueError("density_hu must be strictly ascending")
101
+ if not np.all(values > 0.0):
102
+ raise ValueError("density_values must all be positive (g/cm^3)")
103
+
104
+ thresholds = np.asarray(self.material_thresholds, dtype=np.float64)
105
+ indices = np.asarray(self.material_indices, dtype=np.int32)
106
+ if thresholds.size and not np.all(np.diff(thresholds) > 0.0):
107
+ raise ValueError("material_thresholds must be strictly ascending")
108
+ if indices.size != thresholds.size + 1:
109
+ raise ValueError(
110
+ f"{thresholds.size} thresholds define {thresholds.size + 1} bins, "
111
+ f"but got {indices.size} material_indices"
112
+ )
113
+ if np.any((indices < 0) | (indices >= len(MATERIALS))):
114
+ raise ValueError("material_indices must all be valid registry indices")
115
+
116
+ object.__setattr__(self, "_density_hu_arr", hu)
117
+ object.__setattr__(self, "_density_val_arr", values)
118
+ object.__setattr__(self, "_threshold_arr", thresholds)
119
+ object.__setattr__(self, "_index_arr", indices)
120
+
121
+ def density(self, hu: npt.ArrayLike) -> npt.NDArray[np.float64]:
122
+ """Mass density in g/cm^3 for the given HU (scalar or array), clamped at the ends."""
123
+ return np.interp(
124
+ np.asarray(hu, dtype=np.float64), self._density_hu_arr, self._density_val_arr
125
+ )
126
+
127
+ def material(self, hu: npt.ArrayLike) -> npt.NDArray[np.int32]:
128
+ """Registry material index for the given HU (scalar or array).
129
+
130
+ ``np.digitize`` places each HU in its bin (left-closed, ``x < threshold``);
131
+ the bin index gathers the material index.
132
+ """
133
+ bins = np.digitize(np.asarray(hu, dtype=np.float64), self._threshold_arr)
134
+ return self._index_arr[bins]
135
+
136
+
137
+ DEFAULT_CALIBRATION = HounsfieldCalibration(
138
+ density_hu=_DEFAULT_DENSITY_HU,
139
+ density_values=_DEFAULT_DENSITY_VALUES,
140
+ material_thresholds=_DEFAULT_MATERIAL_THRESHOLDS,
141
+ material_indices=_DEFAULT_MATERIAL_INDICES,
142
+ )
143
+ """A runnable, scanner-independent default calibration; see the module docstring."""
144
+
145
+
146
+ def grid_from_hu(
147
+ hu: npt.NDArray[np.float64],
148
+ spacing: tuple[float, float, float],
149
+ *,
150
+ calibration: HounsfieldCalibration = DEFAULT_CALIBRATION,
151
+ origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
152
+ ) -> VoxelGrid:
153
+ """Assemble a :class:`VoxelGrid` from a HU volume and a voxel ``spacing`` (cm).
154
+
155
+ Pure NumPy: ``hu`` is a ``(nx, ny, nz)`` array of CT numbers already in the engine's
156
+ axis order. The calibration maps it to per-voxel density and material; the grid
157
+ validates the result (positive density, in-registry material).
158
+ """
159
+ hu = np.asarray(hu, dtype=np.float64)
160
+ nx, ny, nz = hu.shape
161
+ return VoxelGrid(
162
+ shape=(nx, ny, nz),
163
+ spacing=spacing,
164
+ origin=origin,
165
+ density=calibration.density(hu),
166
+ material=calibration.material(hu).astype(np.int32),
167
+ )
168
+
169
+
170
+ def grid_from_image(
171
+ image: sitk.Image,
172
+ *,
173
+ calibration: HounsfieldCalibration = DEFAULT_CALIBRATION,
174
+ ) -> VoxelGrid:
175
+ """Convert a 3D SimpleITK CT image into a :class:`VoxelGrid`.
176
+
177
+ Handles the frame mismatch between ITK and the engine: SimpleITK indexes ``(z, y, x)``
178
+ and works in mm with the physical origin at voxel (0,0,0)'s *centre*, while the grid
179
+ is ``(x, y, z)`` in cm with the origin at that voxel's lower *corner*. The image's
180
+ intensities are taken to be Hounsfield units already (true for NIfTI/NRRD and for a
181
+ rescale-applied DICOM series).
182
+
183
+ Only axis-aligned CTs are supported: the direction cosine matrix must be diagonal.
184
+ A negative diagonal entry (an LPS/RAS sign flip, common clinically) means that axis's
185
+ physical coordinate decreases with index; the array is flipped and the origin
186
+ recomputed so the grid is stored in increasing-coordinate order. An oblique
187
+ (off-diagonal) orientation raises — resample to an axis-aligned grid first.
188
+ """
189
+ import SimpleITK as sitk
190
+
191
+ if image.GetDimension() != 3:
192
+ raise ValueError(f"expected a 3D CT image, got {image.GetDimension()}D")
193
+
194
+ direction = np.asarray(image.GetDirection(), dtype=np.float64).reshape(3, 3)
195
+ if not np.allclose(direction - np.diag(np.diagonal(direction)), 0.0, atol=1.0e-6):
196
+ raise ValueError(
197
+ "oblique CT orientation is not supported; resample to an axis-aligned grid first"
198
+ )
199
+
200
+ # SimpleITK array is (z, y, x); transpose to the engine's (x, y, z). Spacing, origin
201
+ # and the direction diagonal are all in image (x, y, z) order, so they line up.
202
+ hu = np.transpose(sitk.GetArrayFromImage(image).astype(np.float64), (2, 1, 0))
203
+ spacing_mm = np.asarray(image.GetSpacing(), dtype=np.float64)
204
+ origin_mm = np.asarray(image.GetOrigin(), dtype=np.float64)
205
+ signs = np.sign(np.diagonal(direction))
206
+
207
+ first_center_mm = origin_mm.copy()
208
+ for axis in range(3):
209
+ if signs[axis] < 0.0:
210
+ hu = np.flip(hu, axis=axis)
211
+ # Physical coordinate fell with index, so the minimum-coordinate voxel was
212
+ # the last one; after the flip it is index 0.
213
+ first_center_mm[axis] = origin_mm[axis] - spacing_mm[axis] * (hu.shape[axis] - 1)
214
+
215
+ lower_corner_mm = first_center_mm - 0.5 * spacing_mm
216
+ ox, oy, oz = (lower_corner_mm / _MM_PER_CM).tolist()
217
+ sx, sy, sz = (spacing_mm / _MM_PER_CM).tolist()
218
+ return grid_from_hu(
219
+ np.ascontiguousarray(hu),
220
+ spacing=(sx, sy, sz),
221
+ calibration=calibration,
222
+ origin=(ox, oy, oz),
223
+ )
224
+
225
+
226
+ def read_ct(
227
+ path: str,
228
+ *,
229
+ calibration: HounsfieldCalibration = DEFAULT_CALIBRATION,
230
+ ) -> VoxelGrid:
231
+ """Read a CT image file into a :class:`VoxelGrid` (needs the ``pyradmc[ct]`` extra).
232
+
233
+ Accepts anything SimpleITK reads as a single volume — NIfTI, NRRD, MetaImage, a
234
+ single multi-frame DICOM. For a DICOM *series* (a directory of slices), read it with
235
+ ``SimpleITK.ImageSeriesReader`` yourself and pass the image to :func:`grid_from_image`,
236
+ so slice ordering and the rescale slope/intercept are handled explicitly. See
237
+ :func:`grid_from_image` for the frame and orientation handling.
238
+ """
239
+ try:
240
+ import SimpleITK as sitk
241
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
242
+ raise ImportError("read_ct needs the optional CT extra: pip install 'pyradmc[ct]'") from exc
243
+
244
+ return grid_from_image(sitk.ReadImage(path), calibration=calibration)
@@ -0,0 +1 @@
1
+ """Subpackage placeholder; see AGENTS.md before adding code."""
@@ -0,0 +1,5 @@
1
+ """The pure-NumPy reference backend: the oracle. Never optimized (AGENTS.md 2.2)."""
2
+
3
+ from pyradmc.backends.ref.engine import ReferenceEngine, TransportResult
4
+
5
+ __all__ = ["ReferenceEngine", "TransportResult"]