Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: 9e51231410e17f8cc195065e8c17c073107c76535ccf7787b1190dc6aa765052
1"""Host-only contracts for fixed-grid photon transport estimators.23Supplied linear coefficients in inverse millimetres define absorption plus either4isotropic elastic or free-electron Klein-Nishina/Compton scattering. The latter5changes energy and uses an explicit coefficient energy table; it does not include6bound-electron, polarisation or coherent-scattering corrections. The material7grid is piecewise constant, unlike the deterministic projector's trilinear field.8"""910from __future__ import annotations1112import math13from dataclasses import dataclass14from enum import IntEnum15from itertools import pairwise16from typing import Any, Literal, cast1718from dpt.contracts import ContractError, finite_scalar192021class TransportError(ContractError):22"""Compatibility subtype for transport physical, storage and execution contracts."""232425class IncompleteHistoryError(RuntimeError):26"""A resource guard stopped a history; the entire estimate is invalid."""272829class HistoryStatus(IntEnum):30"""Each launched history writes exactly one terminal status."""3132ESCAPED = 033ABSORBED = 134EVENT_BUDGET = 235CROSSING_BUDGET = 336NUMERICAL_FAILURE = 437ENERGY_SUPPORT = 538ANGLE_BUDGET = 6394041def _positive_integer(value: int, name: str) -> None:42if type(value) is not int or not 0 < value < 2**31:43raise TransportError(f"{name} must be a positive signed 32-bit integer")444546def _physical(value: Any, name: str, *, positive: bool = False, nonnegative: bool = False) -> float:47try:48scalar = finite_scalar(value, name, minimum=0.0 if positive or nonnegative else None)49except ContractError as error:50raise TransportError(str(error)) from error51if positive and scalar == 0:52raise TransportError(f"{name} must be positive")53return scalar545556def _freeze_fields(instance: Any, names: tuple[str, ...]) -> None:57for name in names:58object.__setattr__(instance, name, tuple(getattr(instance, name)))596061@dataclass(frozen=True, slots=True)62class MaterialGrid:63"""Axis-aligned cell faces; shape is (nz, ny, nx), device indices x fastest."""6465origin_mm: tuple[float, float, float]66spacing_mm: tuple[float, float, float]67shape: tuple[int, int, int]68materials: int6970def __post_init__(self) -> None:71_freeze_fields(self, ("origin_mm", "spacing_mm", "shape"))72if len(self.origin_mm) != 3 or len(self.spacing_mm) != 3 or len(self.shape) != 3:73raise TransportError("origin, spacing and shape must each have three components")74_positive_integer(self.materials, "materials")75for count, step, origin in zip(76self.shape_xyz, self.spacing_mm, self.origin_mm, strict=True77):78_positive_integer(count, "grid extent")79_physical(step, "cell spacing", positive=True)80_physical(origin, "cell origin")81endpoint = origin + count * step82if (83not math.isfinite(endpoint)84or origin + step == origin85or endpoint - step == endpoint86):87raise TransportError("cell faces must remain distinguishable in binary64")88if math.prod(self.shape) >= 2**31:89raise TransportError("the flattened material grid exceeds signed 32-bit indexing")9091@property92def shape_xyz(self) -> tuple[int, int, int]:93"""Geometric extents for device vectors; storage shape remains (z,y,x)."""94return self.shape[2], self.shape[1], self.shape[0]9596@property97def upper_mm(self) -> tuple[float, float, float]:98return cast(99tuple[float, float, float],100tuple(101start + count * step102for start, count, step in zip(103self.origin_mm, self.shape_xyz, self.spacing_mm, strict=True104)105),106)107108109@dataclass(frozen=True, slots=True)110class PlanarDetector:111"""An outward-facing z plane, pixel lower faces (x,y), and count score.112113Pixel support is half-open. The detector must be above the entire material114grid; an escaped straight ray can therefore hit it at most once. A recorded115score includes both uncollided and scattered photons. Do not add a separate116deterministic primary image to this estimate.117"""118119lower_xy_mm: tuple[float, float]120spacing_xy_mm: tuple[float, float]121shape: tuple[int, int] # width, height122z_mm: float123124def __post_init__(self) -> None:125_freeze_fields(self, ("lower_xy_mm", "spacing_xy_mm", "shape"))126_physical(self.z_mm, "detector z")127if any(len(value) != 2 for value in (self.lower_xy_mm, self.spacing_xy_mm, self.shape)):128raise TransportError("detector xy fields must have two components")129for count, step, lower in zip(130self.shape, self.spacing_xy_mm, self.lower_xy_mm, strict=True131):132_positive_integer(count, "detector extent")133_physical(step, "pixel spacing", positive=True)134_physical(lower, "detector origin")135end = lower + count * step136if not math.isfinite(end):137raise TransportError("detector endpoint must be finite")138if lower + step == lower or end - step == end:139raise TransportError("detector pixels must be distinguishable in binary64")140if self.pixels >= 2**31:141raise TransportError("the flattened detector exceeds signed 32-bit indexing")142143@property144def pixels(self) -> int:145return math.prod(self.shape)146147148# region book:transport-model-contract149@dataclass(frozen=True, slots=True)150class TransportSpec:151"""A complete physical scope and finite launch-resource policy.152153Source rays and nonnegative importance weights are supplied by the caller.154Their sampling law must be fixed with respect to active material parameters;155the library does not silently invent an emission distribution. Provenance156identifies the user's coefficient source, not a fabricated material asset.157"""158159grid: MaterialGrid160detector: PlanarDetector161energy_kev: float162coefficient_provenance: str163coefficient_energies_kev: tuple[float, ...] = ()164scattering_law: Literal["isotropic-elastic", "free-electron-compton"] = "isotropic-elastic"165scoring: Literal["photon-count", "energy-kev"] = "photon-count"166max_angle_trials: int = 4096167max_events: int = 4096168max_crossings: int = 65536169block_dim: Literal[64, 128, 256] = 128170estimator: Literal["analogue", "continuous-absorption"] = "analogue"171172def __post_init__(self) -> None:173_freeze_fields(self, ("coefficient_energies_kev",))174_physical(self.energy_kev, "source energy", positive=True)175for node in self.coefficient_energies_kev:176_physical(node, "coefficient energy", nonnegative=True)177if type(self.coefficient_provenance) is not str or not self.coefficient_provenance.strip():178raise TransportError("the supplied coefficients require a provenance identifier")179if self.detector.z_mm <= self.grid.upper_mm[2]:180raise TransportError("the detector plane must lie above the entire material grid")181nodes = self.energy_nodes182if any(right <= left for left, right in pairwise(nodes)):183raise TransportError("coefficient energies must be strictly increasing")184if not nodes[0] <= self.energy_kev <= nodes[-1]:185raise TransportError("coefficient energies must contain the source energy")186if self.scattering_law not in ("isotropic-elastic", "free-electron-compton"):187raise TransportError("unsupported scattering law")188if self.scattering_law == "free-electron-compton" and len(nodes) < 2:189raise TransportError("Compton scattering needs energy-dependent coefficient tables")190if self.scoring not in ("photon-count", "energy-kev"):191raise TransportError("unsupported detector score")192if self.estimator not in ("analogue", "continuous-absorption"):193raise TransportError("unsupported transport estimator")194_positive_integer(self.max_angle_trials, "max_angle_trials")195_positive_integer(self.max_events, "max_events")196_positive_integer(self.max_crossings, "max_crossings")197if self.block_dim not in (64, 128, 256):198raise TransportError("block_dim must be 64, 128 or 256")199200@property201def energy_nodes(self) -> tuple[float, ...]:202"""An omitted grid declares coefficients at the one elastic source energy."""203return self.coefficient_energies_kev or (self.energy_kev,)204205206# endregion book:transport-model-contract207