Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: 430e757429d7fec2206eefc4af72db4e903bb017c2ee798db191b3b9399ef246
1"""Deterministic detector operators and explicitly separate observation models.23Expected response, spatial spreading, electronic calibration and random draws4are distinct operations with caller-owned buffers. An energy-integrating mean5is not passed to a Poisson count sampler by the library. Imports are CPU-safe;6all numerical image work uses the single CUDA implementation.7"""89from __future__ import annotations1011# Public Python boundaries validate runtime inputs; workspace scratch stays module-owned.12# pyright: reportPrivateUsage=false, reportUnnecessaryIsInstance=false13import math14from dataclasses import dataclass, field15from typing import Any, Literal1617from dpt._runtime import DeviceContext, load_kernels, prepare_context, require_no_tape18from dpt.contracts import ContractError, NumericalError, finite_scalar, finite_tuple, integer19from dpt.materials import Provenance202122@dataclass(slots=True)23class DetectorWorkspace:24"""Reusable diagnostics and scalar-reduction scratch on one CUDA stream."""2526max_pixels: int27context: DeviceContext28_kernels: Any = field(repr=False)29_checks: Any = field(repr=False)30_status: Any = field(repr=False)31_partials: Any = field(repr=False)3233@property34def reduction_groups(self) -> int:35"""Read-only launch bound derived from the allocated scratch capacity."""36return int(self._partials.size)3738@property39def scratch_bytes(self) -> int:40return 4 + 8 * int(self._partials.size)4142def clear_status(self) -> None:43with self.context.scope():44self._status.zero_()4546def check_status(self) -> None:47"""Synchronise and reject a numerical failure; no failed draw is an observation."""48self.context.wp.synchronize_stream(self.context.stream)49code = int(self._status.numpy()[0])50if code & 1:51raise ContractError("detector device inputs violate their finite/domain/rate contract")52if code & 2:53raise NumericalError("detector output or gradient overflows its declared precision")54if code & 4:55raise NumericalError("Poisson draw budget exhausted; discard the entire realisation")5657def _values(self, arrays: list[tuple[Any, str]]) -> None:58self.clear_status()59for array, domain in arrays:60if not array.size:61continue62kernel = {63"finite": self._checks.check_finite,64"nonnegative": self._checks.check_nonnegative,65"positive": self._kernels.check_positive,66"rate": self._kernels.check_rates,67}[domain]68self.context.wp.launch(69kernel,70dim=array.size,71inputs=[array],72outputs=[self._status],73stream=self.context.stream,74record_tape=False,75)76self.check_status()7778def _pixels(self, values: Any, name: str) -> int:79count = self.context.array(values, name, dtype=self.context.wp.float32)80if count > self.max_pixels:81raise ContractError(f"{name} exceeds workspace capacity")82return count838485def prepare_detector(86*,87max_pixels: int,88device: str = "cuda:0",89stream: Any = None,90reduction_groups: int = 64,91) -> DetectorWorkspace:92"""Prepare O(groups) device scratch; do not allocate any detector image."""93integer(max_pixels, "max_pixels")94integer(reduction_groups, "reduction_groups", minimum=1, maximum=256)95context = prepare_context(device=device, stream=stream)96kernels = load_kernels("dpt.kernels.detector")97checks = load_kernels("dpt.kernels.spectral")98with context.scope():99status = context.wp.zeros(1, dtype=context.wp.int32, device=context.device)100partials = context.wp.empty(101reduction_groups, dtype=context.wp.float64, device=context.device102)103return DetectorWorkspace(max_pixels, context, kernels, checks, status, partials)104105106# region book:calibration-identifiability107@dataclass(frozen=True, slots=True)108class CalibrationSpec:109"""y = gain * exposure * mean + offset, in the declared output unit.110111Gain is strictly positive; exposure is non-negative; offset is any finite112electronic baseline. A fixed flat-field may vary by pixel. Optimised gain113and offset are shared scalars: unconstrained correction images are excluded.114Exposure and gain cannot both be active because their scale is unidentifiable.115"""116117output_unit: str118shared_gain: bool = True119shared_offset: bool = True120active_mean: bool = True121active_gain: bool = False122active_exposure: bool = False123active_offset: bool = False124125def __post_init__(self) -> None:126if not self.output_unit.strip():127raise ContractError("calibration output unit is required")128for name in (129"shared_gain",130"shared_offset",131"active_mean",132"active_gain",133"active_exposure",134"active_offset",135):136if type(getattr(self, name)) is not bool:137raise ContractError(f"{name} must be a boolean")138if self.active_gain and self.active_exposure:139raise ContractError("fix gain or exposure before fitting the other scale")140if self.active_gain and not self.shared_gain:141raise ContractError("a fitted gain must be a shared acquisition parameter")142if self.active_offset and not self.shared_offset:143raise ContractError("a fitted offset must be a shared acquisition parameter")144145146# endregion book:calibration-identifiability147148149def _calibration_inputs(150mean: Any,151gain: Any,152exposure: Any,153offset: Any,154spec: CalibrationSpec,155workspace: DetectorWorkspace,156) -> tuple[int, list[tuple[str, Any]]]:157ctx = workspace.context158pixels = workspace._pixels(mean, "mean")159for name, array, size in (160("gain", gain, 1 if spec.shared_gain else pixels),161("exposure", exposure, 1),162("offset", offset, 1 if spec.shared_offset else pixels),163):164ctx.array(array, name, dtype=ctx.wp.float32, shape=(size,))165return pixels, [("mean", mean), ("gain", gain), ("exposure", exposure), ("offset", offset)]166167168def calibrate(169mean: Any,170gain: Any,171exposure: Any,172offset: Any,173*,174out_signal: Any,175spec: CalibrationSpec,176workspace: DetectorWorkspace,177stream: Any = None,178validate: bool = True,179) -> None:180"""Apply deterministic calibration with one fused pixel launch and FP64 intermediates."""181require_no_tape()182ctx = workspace.context183ctx.assert_stream(stream)184pixels, reads = _calibration_inputs(mean, gain, exposure, offset, spec, workspace)185ctx.array(out_signal, "out_signal", dtype=ctx.wp.float32, shape=(pixels,))186ctx.disjoint(reads, [("out_signal", out_signal)])187with ctx.scope():188if validate:189workspace._values(190[191(mean, "nonnegative"),192(gain, "positive"),193(exposure, "nonnegative"),194(offset, "finite"),195]196)197if pixels:198ctx.wp.launch(199workspace._kernels.get_calibration(spec.shared_gain, spec.shared_offset),200dim=pixels,201inputs=[mean, gain, exposure, offset],202outputs=[out_signal, workspace._status],203stream=ctx.stream,204record_tape=False,205)206if validate:207workspace.check_status()208209210def calibration_vjp(211mean: Any,212gain: Any,213exposure: Any,214offset: Any,215*,216seed: Any,217out_grad_mean: Any = None,218out_grad_gain: Any = None,219out_grad_exposure: Any = None,220out_grad_offset: Any = None,221spec: CalibrationSpec,222workspace: DetectorWorkspace,223stream: Any = None,224validate: bool = True,225) -> None:226"""Overwrite first-order products; shared nuisance reductions use FP64 trees.227228Per-image cotangents use binary32. Each requested shared scalar destination229may be binary32 or binary64. Use binary64 before a logarithmic parameter230chart: its scale factor can recover a derivative too small for binary32.231"""232require_no_tape()233ctx = workspace.context234ctx.assert_stream(stream)235pixels, reads = _calibration_inputs(mean, gain, exposure, offset, spec, workspace)236ctx.array(seed, "seed", dtype=ctx.wp.float32, shape=(pixels,))237writes: list[tuple[str, Any]] = []238for name, output, active, size in (239("mean", out_grad_mean, spec.active_mean, pixels),240("gain", out_grad_gain, spec.active_gain, 1),241("exposure", out_grad_exposure, spec.active_exposure, 1),242("offset", out_grad_offset, spec.active_offset, 1),243):244if output is not None:245if not active:246raise ContractError(f"calibration {name} was declared fixed")247dtype = ctx.wp.float32248if name != "mean":249dtype = getattr(output, "dtype", None)250if dtype not in (ctx.wp.float32, ctx.wp.float64):251raise ContractError("shared calibration gradients must be binary32 or binary64")252ctx.array(output, f"out_grad_{name}", dtype=dtype, shape=(size,))253writes.append((f"out_grad_{name}", output))254if not writes:255raise ContractError("request at least one active calibration gradient")256ctx.disjoint([*reads, ("seed", seed)], writes)257with ctx.scope():258if validate:259workspace._values(260[261(mean, "nonnegative"),262(gain, "positive"),263(exposure, "nonnegative"),264(offset, "finite"),265(seed, "finite"),266]267)268if pixels and out_grad_mean is not None:269ctx.wp.launch(270workspace._kernels.get_calibration_pixel_vjp(spec.shared_gain, True),271dim=pixels,272inputs=[gain, exposure, seed],273outputs=[out_grad_mean, workspace._status],274stream=ctx.stream,275record_tape=False,276)277groups = min(workspace.reduction_groups, max(1, (pixels + 255) // 256))278for kind, output in enumerate((out_grad_gain, out_grad_exposure, out_grad_offset)):279if output is None:280continue281if not pixels:282output.zero_()283continue284ctx.wp.launch_tiled(285workspace._kernels.get_calibration_partials(spec.shared_gain, kind),286dim=groups,287block_dim=256,288inputs=[mean, gain, exposure, seed, pixels, groups],289outputs=[workspace._partials],290stream=ctx.stream,291record_tape=False,292)293ctx.wp.launch(294workspace._checks.get_finish_shared(output.dtype == ctx.wp.float64),295dim=1,296inputs=[workspace._partials, groups],297outputs=[output, workspace._status],298stream=ctx.stream,299record_tape=False,300)301if validate:302workspace.check_status()303304305@dataclass(frozen=True, slots=True)306class BlurSpec:307"""Finite detector response: Bx[r,c] = sum_ij h[i,j] x[r+i-a,c+j-b].308309Samples outside the detector are zero. The non-negative odd-sized kernel310sums to at most one, representing a passive spread with optional loss.311The edge is never renormalised; transpose therefore reverses the offsets.312"""313314height: int315width: int316kernel_height: int317kernel_width: int318weights: tuple[float, ...]319provenance: Provenance320boundary: Literal["zero"] = "zero"321322def __post_init__(self) -> None:323for name in ("height", "width", "kernel_height", "kernel_width"):324integer(getattr(self, name), name, minimum=1)325if self.height * self.width > 2**31 - 1:326raise ContractError("detector dimensions exceed the flat index range")327if self.kernel_height % 2 != 1 or self.kernel_width % 2 != 1:328raise ContractError("spatial kernel dimensions must be odd, with a unique centre")329finite_tuple(self.weights, "blur weights")330if len(self.weights) != self.kernel_height * self.kernel_width:331raise ContractError("blur weights must match the declared kernel footprint")332if math.fsum(self.weights) > 1.0:333raise ContractError("passive blur weights must sum to at most one")334if self.boundary != "zero":335raise ContractError("only explicit zero-extension boundaries are supported")336if not isinstance(self.provenance, Provenance):337raise ContractError("spatial-response provenance is required")338339340@dataclass(slots=True)341class BlurWorkspace:342spec: BlurSpec343detector: DetectorWorkspace344_weights: Any = field(repr=False)345346@property347def scratch_bytes(self) -> int:348return self.detector.scratch_bytes + 8 * int(self._weights.size)349350def clear_status(self) -> None:351self.detector.clear_status()352353def check_status(self) -> None:354self.detector.check_status()355356357def prepare_blur(spec: BlurSpec, *, device: str = "cuda:0", stream: Any = None) -> BlurWorkspace:358"""Upload the fixed stencil in binary64, preserving its declared host coefficients.359360Stencils are small immutable calibration data. Narrowing their coefficients361to binary32 can erase a positive tail whose product with a bright input is362representable, and can change passive total mass. Images still use binary32.363"""364detector = prepare_detector(max_pixels=spec.height * spec.width, device=device, stream=stream)365ctx = detector.context366with ctx.scope():367weights = ctx.wp.array(spec.weights, dtype=ctx.wp.float64, device=ctx.device)368return BlurWorkspace(spec, detector, weights)369370371def _apply_blur(372source: Any,373output: Any,374workspace: BlurWorkspace,375transpose: bool,376stream: Any,377validate: bool,378) -> None:379require_no_tape()380spec, detector = workspace.spec, workspace.detector381ctx = detector.context382ctx.assert_stream(stream)383size = spec.height * spec.width384ctx.array(source, "source", dtype=ctx.wp.float32, shape=(size,))385ctx.array(output, "output", dtype=ctx.wp.float32, shape=(size,))386ctx.disjoint([("source", source), ("weights", workspace._weights)], [("output", output)])387with ctx.scope():388if validate:389detector._values([(source, "finite")])390ctx.wp.launch(391detector._kernels.get_blur(392spec.height, spec.width, spec.kernel_height, spec.kernel_width, transpose393),394dim=size,395inputs=[source, workspace._weights],396outputs=[output, detector._status],397stream=ctx.stream,398record_tape=False,399)400if validate:401detector.check_status()402403404def blur(405source: Any,406*,407out_signal: Any,408workspace: BlurWorkspace,409stream: Any = None,410validate: bool = True,411) -> None:412"""Apply the declared finite spatial response; inputs may be signed real signals."""413_apply_blur(source, out_signal, workspace, False, stream, validate)414415416def blur_transpose(417seed: Any,418*,419out_grad_signal: Any,420workspace: BlurWorkspace,421stream: Any = None,422validate: bool = True,423) -> None:424"""Apply the exact algebraic transpose of the same zero-extended discrete matrix."""425_apply_blur(seed, out_grad_signal, workspace, True, stream, validate)426427428# region book:observation-identity429@dataclass(frozen=True, slots=True)430class ObservationIdentity:431"""A reproducible random experiment independent of launch tiling.432433Counter identity = (observation_id << 32) | global_pixel. Poisson bin k has434domain 1 + energy_offset + k; electronic read noise has domain 0x7fffffff.435Transport owns domain 0 and domains >= 0x80000000. Reusing an identity is436common random numbers, not an independent replication. Keep bin identities437stable when batching, and assign a fresh observation_id for a fresh image.438``draw_budget`` bounds rejection proposals, never the sampled count. Below439rate 10 an inverse-CDF draw uses one proposal with a fixed internal work440bound; higher rates use at most ``draw_budget`` transformed proposals.441"""442443seed: int444observation_id: int445pixel_offset: int = 0446energy_offset: int = 0447draw_budget: int = 256448449def __post_init__(self) -> None:450integer(self.seed, "seed", maximum=2**64 - 1)451integer(self.observation_id, "observation_id", maximum=2**32 - 1)452integer(self.pixel_offset, "pixel_offset", maximum=2**32 - 1)453integer(self.energy_offset, "energy_offset", maximum=2**31 - 3)454integer(self.draw_budget, "draw_budget", minimum=1)455456def validate_extent(self, pixels: int, energies: int = 1) -> None:457integer(pixels, "pixels")458integer(energies, "energies", minimum=1)459if self.pixel_offset + pixels > 2**32:460raise ContractError("pixel identities would wrap")461if self.energy_offset + energies > 2**31 - 2:462raise ContractError("Poisson domains would overlap the read-noise/transport namespace")463464465# endregion book:observation-identity466467468def sample_poisson_counts(469expected_counts: Any,470*,471out_counts: Any,472identity: ObservationIdentity,473workspace: DetectorWorkspace,474stream: Any = None,475validate: bool = True,476) -> None:477"""Draw uint64 independent counts from count-domain means in [0, 10⁹].478479This is an observation generator, not a differentiable forward operator.480Its input must already be a photon-counting mean. A zero mean returns zero;481rejection-budget exhaustion invalidates the realisation. No noise or482non-negativity clipping is applied to calibrated electronic signals here.483The declared upper rate is an implementation boundary, not an executed484acceptance result; large-rate mass accuracy and sampling remain CUDA gates.485"""486require_no_tape()487ctx = workspace.context488ctx.assert_stream(stream)489pixels = workspace._pixels(expected_counts, "expected_counts")490identity.validate_extent(pixels)491ctx.array(out_counts, "out_counts", dtype=ctx.wp.uint64, shape=(pixels,))492ctx.disjoint([("expected_counts", expected_counts)], [("out_counts", out_counts)])493with ctx.scope():494if validate:495workspace._values([(expected_counts, "rate")])496if pixels:497ctx.wp.launch(498workspace._kernels.poisson_counts,499dim=pixels,500inputs=[501expected_counts,502ctx.wp.uint64(identity.seed),503ctx.wp.uint32(identity.observation_id),504ctx.wp.uint32(identity.pixel_offset),505ctx.wp.uint32(identity.energy_offset),506identity.draw_budget,507],508outputs=[out_counts, workspace._status],509stream=ctx.stream,510record_tape=False,511)512if validate:513workspace.check_status()514515516def sample_compound_poisson(517detected_bin_means: Any,518photon_scores: Any,519*,520energies: int,521shared_scores: bool,522out_signal: Any,523identity: ObservationIdentity,524workspace: DetectorWorkspace,525stream: Any = None,526validate: bool = True,527) -> None:528"""Draw sum_k score[k,p] N[k,p], N[k,p] ~ Poisson(detected_bin_means[k,p]).529530The independent rate array is energy-major (K,P). Scores are deterministic531output per detected photon; an arbitrary mean response does not specify this532law. The variance before spatial spread is sum_k rate[k,p] score[k,p]².533Apply spatial spread to this realisation, then add independent electronic534noise if that order describes the actual detector. Energy-response variance535within a bin requires a different, explicitly supplied observation law.536"""537require_no_tape()538integer(energies, "energies", minimum=1, maximum=65536)539if type(shared_scores) is not bool:540raise ContractError("shared_scores must be a boolean")541ctx = workspace.context542ctx.assert_stream(stream)543size = ctx.array(detected_bin_means, "detected_bin_means", dtype=ctx.wp.float32)544if size % energies:545raise ContractError("detected bin means must have energy-major shape (K,P)")546pixels = size // energies547if pixels > workspace.max_pixels or size > 2**31 - 1:548raise ContractError("detected bin means exceed the declared pixel/index capacity")549identity.validate_extent(pixels, energies)550ctx.array(551photon_scores,552"photon_scores",553dtype=ctx.wp.float32,554shape=(energies * (1 if shared_scores else pixels),),555)556ctx.array(out_signal, "out_signal", dtype=ctx.wp.float32, shape=(pixels,))557ctx.disjoint(558[("detected_bin_means", detected_bin_means), ("photon_scores", photon_scores)],559[("out_signal", out_signal)],560)561with ctx.scope():562if validate:563workspace._values([(detected_bin_means, "rate"), (photon_scores, "nonnegative")])564if pixels:565ctx.wp.launch(566workspace._kernels.get_compound_poisson(energies, shared_scores),567dim=pixels,568inputs=[569detected_bin_means,570photon_scores,571pixels,572ctx.wp.uint64(identity.seed),573ctx.wp.uint32(identity.observation_id),574ctx.wp.uint32(identity.pixel_offset),575ctx.wp.uint32(identity.energy_offset),576identity.draw_budget,577],578outputs=[out_signal, workspace._status],579stream=ctx.stream,580record_tape=False,581)582if validate:583workspace.check_status()584585586def add_gaussian_read_noise(587signal: Any,588*,589standard_deviation: float,590out_signal: Any,591identity: ObservationIdentity,592workspace: DetectorWorkspace,593stream: Any = None,594validate: bool = True,595) -> None:596"""Add independent electronic Gaussian noise in the signal's units, without clipping."""597require_no_tape()598sigma = finite_scalar(standard_deviation, "standard_deviation", minimum=0)599ctx = workspace.context600ctx.assert_stream(stream)601pixels = workspace._pixels(signal, "signal")602identity.validate_extent(pixels)603ctx.array(out_signal, "out_signal", dtype=ctx.wp.float32, shape=(pixels,))604ctx.disjoint([("signal", signal)], [("out_signal", out_signal)])605with ctx.scope():606if validate:607workspace._values([(signal, "finite")])608if pixels:609ctx.wp.launch(610workspace._kernels.gaussian_read_noise,611dim=pixels,612inputs=[613signal,614sigma,615ctx.wp.uint64(identity.seed),616ctx.wp.uint32(identity.observation_id),617ctx.wp.uint32(identity.pixel_offset),618],619outputs=[out_signal, workspace._status],620stream=ctx.stream,621record_tape=False,622)623if validate:624workspace.check_status()625