Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: d712892fbda5f87010a52f2261584924007bbf856e4afc7c1bfcbe29192be936
1"""Explicit, CPU-safe ingestion of primary attenuation coefficients.23There are no bundled physical tables. A caller supplies the values, identifies4where they came from and records the rights under which they may be used.5Interpolation is confined to the supplied support; repeated energies encode the6two one-sided values at an absorption edge rather than an interval to smooth.7"""89from __future__ import annotations1011# Public Python boundaries validate runtime inputs; workspace scratch stays module-owned.12# pyright: reportPrivateUsage=false, reportUnnecessaryIsInstance=false13import math14import re15from bisect import bisect_left, bisect_right16from dataclasses import dataclass17from itertools import pairwise18from typing import Literal1920from dpt.contracts import ContractError, finite_scalar, finite_tuple212223@dataclass(frozen=True, slots=True)24class Provenance:25"""Identity of the actual input, distinct from a bibliographic citation.2627``sha256`` identifies the original source bytes, before unit conversion.28Analytic fixtures must say so in ``description`` and cite their defining29formula in ``source``; a real material name is not fixture provenance.30"""3132source: str33sha256: str34rights: str35description: str3637def __post_init__(self) -> None:38for name in ("source", "rights", "description"):39value = getattr(self, name)40if not isinstance(value, str) or not value.strip():41raise ContractError(f"provenance {name} must be non-empty")42if not isinstance(self.sha256, str) or re.fullmatch(r"[0-9a-f]{64}", self.sha256) is None:43raise ContractError("provenance sha256 must contain 64 lowercase hexadecimal digits")444546def validate_energy_grid(energies_kev: tuple[float, ...]) -> None:47"""Validate distinct, positive keV nodes shared by spectral inputs and response."""48finite_tuple(energies_kev, "energies_kev", positive=True)49if any(a >= b for a, b in pairwise(energies_kev)):50raise ContractError("energy nodes must be strictly increasing")515253def _log_ratio(high: float, low: float) -> float:54"""Retain adjacent large energies without overflowing a widely separated ratio."""55relative = (high - low) / low56return math.log1p(relative) if math.isfinite(relative) else math.log(high) - math.log(low)575859# region book:material-table-contract60@dataclass(frozen=True, slots=True)61class MaterialTable:62"""Total primary attenuation at one declared material composition/density.6364Arrays are immutable host metadata, never an alternative production backend.65Energies are keV. Duplicate adjacent energies encode below/above edge values,66in that order; at most two entries may share an energy. The density attached67to a mass table is in g/cm³. Linear coefficients already include density.68"""6970name: str71energies_kev: tuple[float, ...]72coefficients: tuple[float, ...]73units: Literal["mm^-1", "cm^-1", "cm^2/g"]74provenance: Provenance75density_g_cm3: float | None = None76interpolation: Literal["linear", "log-log"] = "log-log"7778def __post_init__(self) -> None:79if not self.name.strip():80raise ContractError("a material table needs an unambiguous material/composition name")81if not isinstance(self.provenance, Provenance):82raise ContractError("material-table provenance is required")83finite_tuple(self.energies_kev, "energies_kev", positive=True)84finite_tuple(self.coefficients, "coefficients")85if len(self.energies_kev) != len(self.coefficients):86raise ContractError("energies and coefficients must have equal lengths")87if self.units not in ("mm^-1", "cm^-1", "cm^2/g"):88raise ContractError("unsupported coefficient units")89if self.interpolation not in ("linear", "log-log"):90raise ContractError("interpolation must be linear or log-log")91if self.interpolation == "log-log" and any(c <= 0 for c in self.coefficients):92raise ContractError("log-log interpolation requires strictly positive coefficients")93for index, energy in enumerate(self.energies_kev[1:], 1):94if energy < self.energies_kev[index - 1]:95raise ContractError("energy support must be non-decreasing")96if index > 1 and energy == self.energies_kev[index - 2]:97raise ContractError("an absorption edge has exactly two one-sided entries")98if self.units == "cm^2/g":99density = self.density_g_cm3100density = finite_scalar(density, "reference density in g/cm³", minimum=0)101if density <= 0:102raise ContractError("reference density must be strictly positive")103elif self.density_g_cm3 is not None:104raise ContractError(105"linear coefficients already include density; do not apply it twice"106)107108@property109def linear_mm_inverse(self) -> tuple[float, ...]:110"""Convert source values once, without inventing a different mixture model."""111scale = 1.0112if self.units == "cm^-1":113scale = 0.1114elif self.units == "cm^2/g":115assert self.density_g_cm3 is not None116scale = self.density_g_cm3 / 10.0117converted = tuple(c * scale for c in self.coefficients)118if any(not math.isfinite(c) for c in converted):119raise ContractError("unit conversion overflowed; coefficient table is unusable")120if any(121source > 0 and target == 0122for source, target in zip(self.coefficients, converted, strict=True)123):124raise ContractError("unit conversion underflowed a positive attenuation coefficient")125return converted126127def at_energies(128self,129energies_kev: tuple[float, ...],130*,131edge_side: Literal["below", "above"],132) -> tuple[float, ...]:133"""Interpolate in mm⁻¹, requiring the one-sided convention at exact edges."""134if edge_side not in ("below", "above"):135raise ContractError("edge_side must be explicitly below or above")136finite_tuple(energies_kev, "query energies", positive=True)137grid = self.energies_kev138coefficients = self.linear_mm_inverse139result: list[float] = []140for energy in energies_kev:141if energy < grid[0] or energy > grid[-1]:142raise ContractError(f"{self.name}: {energy} keV lies outside supplied support")143left, right = bisect_left(grid, energy), bisect_right(grid, energy)144if left != right:145result.append(coefficients[left if edge_side == "below" else right - 1])146continue147low, high = left - 1, left148if self.interpolation == "log-log":149fraction = _log_ratio(energy, grid[low]) / _log_ratio(grid[high], grid[low])150value = math.exp(151(1.0 - fraction) * math.log(coefficients[low])152+ fraction * math.log(coefficients[high])153)154else:155fraction = (energy - grid[low]) / (grid[high] - grid[low])156value = (1.0 - fraction) * coefficients[low] + fraction * coefficients[high]157result.append(value)158return tuple(result)159160161# endregion book:material-table-contract162163164@dataclass(frozen=True, slots=True)165class MaterialBasis:166"""Fixed-density, non-negative dimensionless basis fields integrated in mm.167168``mixing_assumption`` states whether fields are volume fractions, material169indicators or relative concentrations. There is no implicit HU conversion.170Spatially varying mass density must be included in the declared basis field.171"""172173tables: tuple[MaterialTable, ...]174mixing_assumption: str175176def __post_init__(self) -> None:177if not isinstance(self.tables, tuple) or not self.tables:178raise ContractError("a material basis requires an immutable tuple of tables")179if any(not isinstance(table, MaterialTable) for table in self.tables):180raise ContractError("material basis entries must be MaterialTable instances")181if len({table.name for table in self.tables}) != len(self.tables):182raise ContractError("material basis names must be unique")183if not self.mixing_assumption.strip():184raise ContractError("the material-basis mixing assumption must be recorded")185186def coefficients_at(187self, energies_kev: tuple[float, ...], *, edge_side: Literal["below", "above"]188) -> tuple[float, ...]:189"""Return material-major (material, energy) coefficients for explicit upload."""190return tuple(191value192for table in self.tables193for value in table.at_energies(energies_kev, edge_side=edge_side)194)195