Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: 9462451f173ef4d75702e6009dc55e8f4080a6296c21252a971366291302e022
1"""GPU-resident polychromatic primary transmission from material paths.23Importing this module is CPU-safe. Explicit host data preparation precedes CUDA4uploads; paths and signals have an explicit retained precision. Reverse products5recompute energy depths and preserve original inputs, with no P-by-K tape.6The API intentionally requires explicit VJP composition and rejects ambient tape7recording. It does not differentiate material interpolation or energy nodes.8"""910from __future__ import annotations1112# Public Python boundaries validate runtime inputs; workspace scratch stays module-owned.13# pyright: reportPrivateUsage=false, reportUnnecessaryIsInstance=false14import math15from dataclasses import dataclass, field16from typing import Any, Literal1718from dpt._runtime import DeviceContext, load_kernels, prepare_context, require_no_tape19from dpt.contracts import ContractError, NumericalError, finite_tuple, integer20from dpt.materials import Provenance212223# region book:spectrum-units24@dataclass(frozen=True, slots=True)25class Spectrum:26"""A supplied open-beam spectrum at the detector, before response losses.2728Density values are photons/keV and require positive quadrature weights in29keV. Bin values are already integrated expected photon populations and must30not receive a second energy weight. No normalisation or inverse-square31correction is guessed. Discrete lines may be included as integrated bins.32"""3334energies_kev: tuple[float, ...]35values: tuple[float, ...]36representation: Literal["density", "bin-fluence"]37provenance: Provenance38quadrature_weights_kev: tuple[float, ...] | None = None3940def __post_init__(self) -> None:41finite_tuple(self.energies_kev, "energies_kev", positive=True)42finite_tuple(self.values, "spectrum")43if len(self.values) != len(self.energies_kev):44raise ContractError("spectrum values and energies must have equal length")45if any(a >= b for a, b in zip(self.energies_kev, self.energies_kev[1:], strict=False)):46raise ContractError("spectrum energy nodes must be strictly increasing")47if not isinstance(self.provenance, Provenance):48raise ContractError("spectrum provenance is required")49if self.representation == "density":50if self.quadrature_weights_kev is None:51raise ContractError("a density needs explicit energy quadrature weights")52finite_tuple(self.quadrature_weights_kev, "quadrature weights", positive=True)53if len(self.quadrature_weights_kev) != len(self.values):54raise ContractError("one quadrature weight is required for each density value")55elif self.representation == "bin-fluence":56if self.quadrature_weights_kev is not None:57raise ContractError("bin-integrated fluence must not be weighted twice")58else:59raise ContractError("spectrum representation must be density or bin-fluence")6061@property62def bin_fluence(self) -> tuple[float, ...]:63"""Materialise energy-integrated host values at the explicit ingestion boundary."""64if self.quadrature_weights_kev is None:65return self.values66result = tuple(67value * weight68for value, weight in zip(self.values, self.quadrature_weights_kev, strict=True)69)70if any(not math.isfinite(value) for value in result):71raise ContractError("energy integration overflowed")72if any(73source > 0 and target == 0 for source, target in zip(self.values, result, strict=True)74):75raise ContractError("energy integration underflowed a positive bin population")76return result777879# endregion book:spectrum-units808182@dataclass(frozen=True, slots=True)83class SpectralSpec:84"""Physical array layout and parameter capabilities, fixed at preparation.8586The coefficients are total primary attenuation in mm⁻¹, with material paths87in mm. ``input_description`` records the basis mixing/density assumption and88detector acceptance used to turn source output into per-pixel fluence.89Active weights are bin populations, not unconstrained normalised fractions.90``precision`` selects paths, means, image seeds and path gradients. Supplied91coefficients, weights, responses and their gradients remain binary32.92Retained binary64 rejects underflow of a nonzero exponential or intermediate93product: later multiplication could otherwise conceal a representable tail.94"""9596materials: int97energies: int98coefficients_provenance: tuple[Provenance, ...]99spectrum_provenance: Provenance100response_provenance: Provenance101output_unit: str102input_description: str103shared_weights: bool = True104shared_response: bool = True105active_paths: bool = True106active_weights: bool = False107active_response: bool = False108active_coefficients: bool = False109reduction_groups: int = 64110precision: Literal["float32", "float64"] = "float32"111112def __post_init__(self) -> None:113# Reverse keeps two M-element FP64 vectors per pixel. This bound is an114# explicit implementation envelope, not an empirical performance claim.115if self.precision not in ("float32", "float64"):116raise ContractError("spectral precision must be float32 or float64")117integer(self.materials, "materials", minimum=1, maximum=32)118integer(self.energies, "energies", minimum=1, maximum=65536)119integer(self.reduction_groups, "reduction_groups", minimum=1, maximum=256)120if (121not isinstance(self.coefficients_provenance, tuple)122or len(self.coefficients_provenance) != self.materials123or any(not isinstance(p, Provenance) for p in self.coefficients_provenance)124):125raise ContractError("one immutable provenance record is required per material")126if not isinstance(self.spectrum_provenance, Provenance):127raise ContractError("spectrum provenance is required")128if not isinstance(self.response_provenance, Provenance):129raise ContractError("response provenance is required")130if not self.output_unit.strip() or not self.input_description.strip():131raise ContractError("spectral output units and input assumptions must be recorded")132for name in (133"shared_weights",134"shared_response",135"active_paths",136"active_weights",137"active_response",138"active_coefficients",139):140if type(getattr(self, name)) is not bool:141raise ContractError(f"{name} must be a boolean")142143144@dataclass(slots=True)145class SpectralWorkspace:146"""One stream, persistent diagnostics and bounded shared-parameter reduction."""147148spec: SpectralSpec149max_pixels: int150context: DeviceContext151_kernels: Any = field(repr=False)152_status: Any = field(repr=False)153_empty: Any = field(repr=False)154_empty_signal: Any = field(repr=False)155_partials: Any = field(repr=False)156157@property158def dtype(self) -> Any:159return (160self.context.wp.float64 if self.spec.precision == "float64" else self.context.wp.float32161)162163@property164def scratch_bytes(self) -> int:165return 4 + 8 * int(self._partials.size)166167def clear_status(self) -> None:168with self.context.scope():169self._status.zero_()170171def check_status(self) -> None:172"""Explicit completion point; output/gradient overflow invalidates the pass."""173self.context.wp.synchronize_stream(self.context.stream)174code = int(self._status.numpy()[0])175if code & 1:176raise ContractError("spectral inputs contain nonfinite or negative values")177if code & 4:178raise NumericalError(179"spectral intermediate underflow: unsupported exponent/product range"180)181if code & 2:182raise NumericalError(183"spectral output or gradient cannot be represented in its destination precision"184)185186def _check_values(self, arrays: list[tuple[Any, bool]]) -> None:187self.clear_status()188for values, nonnegative in arrays:189if values.size:190self.context.wp.launch(191self._kernels.get_value_check(192values.dtype == self.context.wp.float64, nonnegative193),194dim=values.size,195inputs=[values],196outputs=[self._status],197stream=self.context.stream,198record_tape=False,199)200self.check_status()201202def validate_inputs(203self,204coefficients: Any,205weights: Any,206response: Any,207*,208pixels: int,209paths: Any = None,210probability_response: bool = False,211stream: Any = None,212) -> None:213"""Validate current supplied arrays without evaluating a spectral image.214215A count observation requires probabilities; a general first-moment216response only requires nonnegative values and its declared output unit.217Omit paths when preparing fixed inputs before the material projection.218"""219require_no_tape()220ctx, spec = self.context, self.spec221ctx.assert_stream(stream)222integer(pixels, "pixels", maximum=self.max_pixels)223if type(probability_response) is not bool:224raise ContractError("probability_response must be a boolean")225arrays = [226(values, True)227for _, values in _fixed_inputs(coefficients, weights, response, pixels, self)228]229if paths is not None:230ctx.array(paths, "paths", dtype=self.dtype, shape=(spec.materials * pixels,))231arrays.append((paths, True))232with ctx.scope():233self._check_values(arrays)234if probability_response and response.size:235ctx.wp.launch(236self._kernels.check_probability,237dim=response.size,238inputs=[response],239outputs=[self._status],240stream=ctx.stream,241record_tape=False,242)243self.check_status()244245246def prepare_spectral(247spec: SpectralSpec,248*,249max_pixels: int,250device: str = "cuda:0",251stream: Any = None,252) -> SpectralWorkspace:253"""Allocate scratch only; callers own source coefficients, weights and destinations."""254integer(max_pixels, "max_pixels", maximum=(2**31 - 1) // max(spec.materials, spec.energies))255context = prepare_context(device=device, stream=stream)256kernels = load_kernels("dpt.kernels.spectral")257parameters = 0258if (spec.active_weights and spec.shared_weights) or (259spec.active_response and spec.shared_response260):261parameters = spec.energies262if spec.active_coefficients:263parameters = spec.materials * spec.energies264with context.scope():265status = context.wp.zeros(1, dtype=context.wp.int32, device=context.device)266empty = context.wp.empty(0, dtype=context.wp.float32, device=context.device)267empty_signal = context.wp.empty(2680,269dtype=context.wp.float64 if spec.precision == "float64" else context.wp.float32,270device=context.device,271)272partials = context.wp.empty(273parameters * spec.reduction_groups,274dtype=context.wp.float64,275device=context.device,276)277return SpectralWorkspace(278spec, max_pixels, context, kernels, status, empty, empty_signal, partials279)280281282def _inputs(283paths: Any,284coefficients: Any,285weights: Any,286response: Any,287workspace: SpectralWorkspace,288) -> tuple[int, list[tuple[str, Any]]]:289ctx, spec = workspace.context, workspace.spec290size = ctx.array(paths, "paths", dtype=workspace.dtype)291if size % spec.materials:292raise ContractError("material-major paths must contain M*P values")293pixels = size // spec.materials294if pixels > workspace.max_pixels:295raise ContractError("material paths exceed workspace pixel capacity")296return pixels, [297("paths", paths),298*_fixed_inputs(coefficients, weights, response, pixels, workspace),299]300301302def _fixed_inputs(303coefficients: Any,304weights: Any,305response: Any,306pixels: int,307workspace: SpectralWorkspace,308) -> list[tuple[str, Any]]:309ctx, spec = workspace.context, workspace.spec310ctx.array(311coefficients, "coefficients", dtype=ctx.wp.float32, shape=(spec.materials * spec.energies,)312)313ctx.array(314weights,315"weights",316dtype=ctx.wp.float32,317shape=(spec.energies * (1 if spec.shared_weights else pixels),),318)319ctx.array(320response,321"response",322dtype=ctx.wp.float32,323shape=(spec.energies * (1 if spec.shared_response else pixels),),324)325return [326("coefficients", coefficients),327("weights", weights),328("response", response),329]330331332# region book:spectral-call-contract333def spectral_signal(334paths: Any,335coefficients: Any,336weights: Any,337response: Any,338*,339out_mean: Any,340workspace: SpectralWorkspace,341stream: Any = None,342validate: bool = True,343) -> None:344"""Compute sum_k weights[k,p]*response[k,p]*exp(-sum_m mu[m,k]*A[m,p]).345346Arrays are flat contiguous CUDA buffers with the prepared precision. ``weights`` are347integrated expected photons per bin, never a spectral density. Checked calls348scan current inputs before writing and synchronise for final range status.349``validate=False`` promises valid current inputs and defers range status to350``workspace.check_status()``. No input may alias a destination.351"""352require_no_tape()353ctx, spec = workspace.context, workspace.spec354ctx.assert_stream(stream)355pixels, reads = _inputs(paths, coefficients, weights, response, workspace)356ctx.array(out_mean, "out_mean", dtype=workspace.dtype, shape=(pixels,))357ctx.disjoint(reads, [("out_mean", out_mean)])358with ctx.scope():359if validate:360workspace.validate_inputs(361coefficients, weights, response, pixels=pixels, paths=paths, stream=ctx.stream362)363if pixels:364ctx.wp.launch(365workspace._kernels.get_forward(366spec.materials,367spec.energies,368spec.shared_weights,369spec.shared_response,370spec.precision == "float64",371),372dim=pixels,373# Smaller blocks pack the register-heavy spectral work more evenly.374# The reduction kernels retain their separate 256-lane layout.375block_dim=128,376inputs=[paths, coefficients, weights, response, pixels],377outputs=[out_mean, workspace._status],378stream=ctx.stream,379record_tape=False,380)381if validate:382workspace.check_status()383384385# endregion book:spectral-call-contract386387388def spectral_vjp(389paths: Any,390coefficients: Any,391weights: Any,392response: Any,393*,394seed: Any,395out_grad_paths: Any = None,396out_grad_weights: Any = None,397out_grad_response: Any = None,398out_grad_coefficients: Any = None,399workspace: SpectralWorkspace,400stream: Any = None,401validate: bool = True,402) -> None:403"""Overwrite selected first-order products, recomputing from unchanged inputs.404405Zero source/response values retain their one-sided algebraic derivatives;406no derivative divides by a weight or by the rounded output. Shared parameter407sums have a fixed split/tree order, and never use floating-point atomics.408Caller-supplied output seeds remain unchanged. Higher derivatives and ambient409tape recording raise rather than returning an incomplete product.410"""411require_no_tape()412ctx, spec = workspace.context, workspace.spec413ctx.assert_stream(stream)414pixels, reads = _inputs(paths, coefficients, weights, response, workspace)415ctx.array(seed, "seed", dtype=workspace.dtype, shape=(pixels,))416destinations = (417("paths", out_grad_paths, spec.active_paths, spec.materials * pixels),418(419"weights",420out_grad_weights,421spec.active_weights,422spec.energies * (1 if spec.shared_weights else pixels),423),424(425"response",426out_grad_response,427spec.active_response,428spec.energies * (1 if spec.shared_response else pixels),429),430(431"coefficients",432out_grad_coefficients,433spec.active_coefficients,434spec.materials * spec.energies,435),436)437writes: list[tuple[str, Any]] = []438for name, output, active, length in destinations:439if output is not None:440if not active:441raise ContractError(f"{name} was declared fixed")442ctx.array(443output,444f"out_grad_{name}",445dtype=workspace.dtype if name == "paths" else ctx.wp.float32,446shape=(length,),447)448writes.append((f"out_grad_{name}", output))449if not writes:450raise ContractError("request at least one active spectral gradient")451ctx.disjoint([*reads, ("seed", seed)], writes)452with ctx.scope():453if validate:454workspace._check_values([*((value, True) for _, value in reads), (seed, False)])455pixel_weights = out_grad_weights is not None and not spec.shared_weights456pixel_response = out_grad_response is not None and not spec.shared_response457if pixels and (out_grad_paths is not None or pixel_weights or pixel_response):458ctx.wp.launch(459workspace._kernels.get_pixel_vjp(460spec.materials,461spec.energies,462spec.shared_weights,463spec.shared_response,464out_grad_paths is not None,465pixel_weights,466pixel_response,467spec.precision == "float64",468),469dim=pixels,470block_dim=128,471inputs=[paths, coefficients, weights, response, seed, pixels],472outputs=[473out_grad_paths if out_grad_paths is not None else workspace._empty_signal,474out_grad_weights if pixel_weights else workspace._empty,475out_grad_response if pixel_response else workspace._empty,476workspace._status,477],478stream=ctx.stream,479record_tape=False,480)481groups = min(spec.reduction_groups, max(1, (pixels + 255) // 256))482for kind, output, shared, parameters in (483(0, out_grad_weights, spec.shared_weights, spec.energies),484(1, out_grad_response, spec.shared_response, spec.energies),485(2, out_grad_coefficients, True, spec.materials * spec.energies),486):487if output is None or not shared:488continue489if pixels:490partial_kernel = (491workspace._kernels.get_coefficient_partials(492spec.materials,493spec.energies,494spec.shared_weights,495spec.shared_response,496spec.precision == "float64",497)498if kind == 2499else workspace._kernels.get_shared_partials(500spec.materials,501spec.energies,502spec.shared_weights,503spec.shared_response,504kind,505spec.precision == "float64",506)507)508ctx.wp.launch_tiled(509partial_kernel,510dim=(groups, spec.energies if kind == 2 else parameters),511block_dim=256,512inputs=[paths, coefficients, weights, response, seed, pixels, groups],513outputs=[workspace._partials, workspace._status],514stream=ctx.stream,515record_tape=False,516)517ctx.wp.launch(518workspace._kernels.finish_shared,519dim=parameters,520inputs=[workspace._partials, groups],521outputs=[output, workspace._status],522stream=ctx.stream,523record_tape=False,524)525else:526output.zero_()527if validate:528workspace.check_status()529530531def spectral_bin_counts(532paths: Any,533coefficients: Any,534weights: Any,535detection_probability: Any,536*,537out_bin_counts: Any,538workspace: SpectralWorkspace,539stream: Any = None,540validate: bool = True,541) -> None:542"""Materialise detected count means in explicit caller-owned (K,P) storage.543544This observation-preparation boundary uses probabilities in [0,1], rather545than an integrator's first-moment response. Feed these independent Poisson546rates and deterministic per-photon scores to ``sample_compound_poisson``.547It deliberately allocates no P*K tensor unless the caller requests that548observation representation. This helper exposes no derivative or tape rule;549differentiate the expected image with ``spectral_vjp`` instead.550"""551require_no_tape()552ctx, spec = workspace.context, workspace.spec553ctx.assert_stream(stream)554pixels, reads = _inputs(paths, coefficients, weights, detection_probability, workspace)555ctx.array(556out_bin_counts, "out_bin_counts", dtype=workspace.dtype, shape=(spec.energies * pixels,)557)558ctx.disjoint(reads, [("out_bin_counts", out_bin_counts)])559with ctx.scope():560if validate:561workspace.validate_inputs(562coefficients,563weights,564detection_probability,565pixels=pixels,566paths=paths,567probability_response=True,568stream=ctx.stream,569)570if pixels:571ctx.wp.launch(572workspace._kernels.get_bin_counts(573spec.materials,574spec.energies,575spec.shared_weights,576spec.shared_response,577spec.precision == "float64",578),579dim=(spec.energies, pixels),580inputs=[paths, coefficients, weights, detection_probability, pixels],581outputs=[out_bin_counts, workspace._status],582stream=ctx.stream,583record_tape=False,584)585if validate:586workspace.check_status()587