Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: 370b1b81ce39ac35c262eed01a7bb3aa91b523fa516c6a29951e2039c9deda12
1"""A fixed CT-derived material-reconstruction example with independent acceptance.23The three commands separate acquisition, fitting and post-freeze evaluation.4The matched coarse generating/inverse basis is intentional and declared; this5is an instructional correctness study, not patient-material validation.6"""78from __future__ import annotations910import argparse11import hashlib12import json13import sys14import time15from dataclasses import asdict, replace16from pathlib import Path17from typing import Any1819import numpy as np2021from dpt.experiments import RunRecorder, experiment_sources, private_output, repository_root22from dpt.geometry import DetectorGeometry, RigidTransform23from dpt.material_reconstruction import (24MaterialReconstruction,25MaterialReconstructionSettings,26MaterialReconstructionView,27)28from dpt.validation.projection import integrate_sampled_field29from dpt.volumes import GridSpec3031STUDY = Path(__file__).resolve().parents[1] / "reconstruction-study"32sys.path.insert(0, str(STUDY))33from _resolution import check_coverage # noqa: E40234from _study import ( # noqa: E40235coarse_cells,36complete_record,37stationarity_gate,38stationarity_result,39)40from probe_acquisition import detector, fixed_metric, helpers, pose_at # noqa: E4024142LIB = helpers()434445def digest(path: Path) -> str:46return hashlib.sha256(path.read_bytes()).hexdigest()474849def grid_from(record: dict[str, Any]) -> GridSpec:50return GridSpec(**{key: tuple(value) for key, value in record.items()})515253def checked_array(folder: Path, name: str, record: dict[str, Any]) -> np.ndarray:54path = folder / name55if digest(path) != record["output_sha256"][name]:56raise ValueError(f"recorded input changed: {name}")57return np.load(path, allow_pickle=False)585960def checked_json(folder: Path, name: str, record: dict[str, Any]) -> dict[str, Any]:61path = folder / name62if digest(path) != record["output_sha256"][name]:63raise ValueError(f"recorded metadata changed: {name}")64return json.loads(path.read_text())656667def require_physics_identity(folder: Path, public: dict[str, Any]) -> None:68if (69digest(folder / "physics.npz") != public["physics_sha256"]70or digest(folder / "metadata.json") != public["physics_metadata_sha256"]71):72raise ValueError("physical arrays or provenance changed after acquisition")737475def sources(config: Path, extra: dict[str, Path]) -> dict[str, Path]:76result = experiment_sources(__file__, config, extra=extra)77for name in ("_study.py", "_resolution.py", "probe_acquisition.py"):78result[f"helpers/{name}"] = STUDY / name79result["helpers/spectral-run.py"] = Path(LIB.__file__)80return result818283def trajectories(config: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:84rows = {}85for role, count in (86("fitting", config["fitting_views_per_ring"]),87("withheld", config["withheld_views_per_ring"]),88):89offset = config["withheld_yaw_offset_degrees"] if role == "withheld" else 0.090rows[role] = [91{92"tilt_degrees": tilt,93"yaw_degrees": offset + j * 360.0 / count,94"pose": asdict(pose_at(tilt, offset + j * 360.0 / count)),95}96for tilt in config["tilts_degrees"]97for j in range(count)98]99return rows100101102def pose_from(row: dict[str, Any]) -> RigidTransform:103return RigidTransform(**{key: tuple(value) for key, value in row["pose"].items()})104105106def poisson(107means: np.ndarray, config: dict[str, Any], phase: int, replicate: int, role: int108) -> tuple[np.ndarray, list[list[int]]]:109values = np.empty_like(means, dtype=np.float32)110keys = []111for view in range(means.shape[0]):112for channel in range(means.shape[1]):113key = [config["noise_root_seed"], phase, replicate, role, view, channel]114sample = np.random.Generator(np.random.PCG64(np.random.SeedSequence(key))).poisson(115means[view, channel].astype(np.float64)116)117if np.any(sample > 2**24):118raise ValueError("Poisson count exceeds exact FP32 integer representation")119values[view, channel] = sample120keys.append(key)121return values, keys122123124def mean_error(actual: np.ndarray, reference: np.ndarray) -> dict[str, float]:125reference = reference.astype(np.float64)126error = (actual.astype(np.float64) - reference) / np.sqrt(reference)127if not np.isfinite(error).all():128raise ValueError("nonfinite Poisson-scaled mean error")129return {130"rms_poisson_sd": float(np.sqrt(np.mean(error**2))),131"p95_poisson_sd": float(np.quantile(np.abs(error), 0.95)),132"maximum_poisson_sd": float(np.max(np.abs(error))),133}134135136def independent_means(137fields: np.ndarray,138grid: GridSpec,139geometry: DetectorGeometry,140pose: RigidTransform,141physics: dict[str, Any],142pixels: list[list[int]],143) -> np.ndarray:144flat = fields.astype(np.float64).reshape(2, -1)145paths = np.array(146[147[148integrate_sampled_field(flat[m], grid, geometry, pose, row, col, validate=False)149for row, col in pixels150]151for m in range(2)152]153)154spectrum = physics["weights"].astype(np.float64) * physics["response"].astype(np.float64)155return spectrum @ np.exp(-physics["mu_mm_inv"].astype(np.float64).T @ paths)156157158def require_sampling(error: dict[str, float], config: dict[str, Any]) -> None:159if (160error["p95_poisson_sd"] > config["quadrature_p95_poisson_sd"]161or error["maximum_poisson_sd"] > config["quadrature_maximum_poisson_sd"]162):163raise ValueError(f"prescribed quadrature failed: {error}")164165166def independent_mapping(fields: np.ndarray, gradient: np.ndarray, step: float) -> float:167"""CPU projection onto the two-material nonnegative unit simplex."""168original = fields.astype(np.float64).reshape(2, -1)169values = original - step * gradient.astype(np.float64).reshape(2, -1)170projected = np.maximum(values, 0.0)171cap = projected.sum(axis=0) > 1.0172projected[0, cap] = np.clip(0.5 * (1.0 + values[0, cap] - values[1, cap]), 0.0, 1.0)173projected[1, cap] = 1.0 - projected[0, cap]174return float(np.max(np.abs((projected - original) / step)))175176177def acquisition_anatomy(178folder: Path, config: dict[str, Any]179) -> tuple[dict[str, Any], np.ndarray, GridSpec]:180"""Admit either the original field or its hash-pinned portable coarse derivative."""181anatomy = json.loads((folder / "anatomy.json").read_text())182native = LIB.checked_array(folder, "forward-fractions.npy", anatomy["outputs"])183if anatomy.get("format") == "worked-material-basis-v1":184if (185digest(folder / "anatomy.json") != config.get("coarse_anatomy_metadata_sha256")186or digest(folder / "forward-fractions.npy") != config.get("coarse_fractions_sha256")187or anatomy["original_forward_fractions_sha256"] != config["forward_fractions_sha256"]188):189raise ValueError("portable anatomy differs from its admitted derivative identity")190fields, grid = native, grid_from(anatomy["forward_grid"])191if tuple(grid.shape) != tuple(config["shape_zyx"]):192raise ValueError("portable material basis has the wrong shape")193else:194if digest(folder / "forward-fractions.npy") != config["forward_fractions_sha256"]:195raise ValueError("native anatomy differs from the prospective input identity")196fields, grid = coarse_cells(197native, grid_from(anatomy["forward_grid"]), tuple(config["shape_zyx"])198)199if {name: row["sha256"] for name, row in anatomy["source_files"].items()} != config[200"source_sha256"201] or anatomy["native_crop_xyz"] != config["native_crop_xyz"]:202raise ValueError("anatomical source or crop differs from the prospective identity")203return anatomy, fields, grid204205206def observation_pins(config: dict[str, Any]) -> dict[str, str]:207"""Admit the exact preserved observations required by the precision repair."""208if config.get("generation_precision", "float32") != "float32":209raise ValueError("observation generation must retain the original float32 precision")210precision = config.get("calculation_precision", "float32")211if precision not in ("float32", "float64"):212raise ValueError("calculation precision must be float32 or float64")213pins = config.get("final_observation_sha256", {})214names = {215f"{folder}/rep{replicate}-{role}-counts.npy"216for replicate in (0, 1)217for folder, role in (("fitting", "fitting"), ("evaluation", "withheld"))218}219if (precision == "float64" or "final_observation_sha256" in config) and (220not isinstance(pins, dict)221or set(pins) != names222or any(223not isinstance(value, str)224or len(value) != 64225or any(character not in "0123456789abcdef" for character in value)226for value in pins.values()227)228):229raise ValueError("the precision repair must pin all four original final count arrays")230return pins231232233def verify_observation_identity(234folder: Path, config: dict[str, Any], development: bool235) -> dict[str, Any]:236"""Check generation output before either fit; no evaluation values enter fitting."""237pins = observation_pins(config)238actual = {name: digest(folder / name) for name in pins}239if pins and not development and actual != pins:240raise ValueError("generated counts differ from the preserved final observations")241return {242"generation_precision": "float32",243"count_files_sha256": actual,244"matches_preserved_final_observations": bool(pins) and not development,245"development": development,246}247248249def acquire(args: argparse.Namespace) -> None:250config = json.loads(args.config.read_text())251observation_pins(config)252anatomy, fields, grid = acquisition_anatomy(args.anatomy, config)253metadata, physics, spec = LIB.load_physics(args.physics)254spec = replace(spec, precision="float32")255if metadata.get("model_id") != config["physics_model_id"]:256raise ValueError("physical inputs do not match the prospective protocol")257if digest(args.physics / "physics.npz") != config["physics_npz_sha256"]:258raise ValueError("physics differs from the prospective input identity")259geometry, rows = detector(config), trajectories(config)260coverage = {role: check_coverage(grid, geometry, records) for role, records in rows.items()}261phase = config["development_noise_phase"] if args.development else config["final_noise_phase"]262public = {263"protocol": config,264"protocol_sha256": digest(args.config),265"grid": asdict(grid),266"geometry": asdict(geometry),267"trajectories": rows,268"metric": fixed_metric(physics),269"noise_phase": phase,270"development": args.development,271"physics_sha256": digest(args.physics / "physics.npz"),272"physics_metadata_sha256": digest(args.physics / "metadata.json"),273"coverage": coverage,274"anatomy_source_files": anatomy["source_files"],275"assignment": config["representation"],276}277extra = {278"inputs/anatomy.json": args.anatomy / "anatomy.json",279"inputs/forward-fractions.npy": args.anatomy / "forward-fractions.npy",280"rights/anatomy.json": args.anatomy / "source-license-manifest.json",281"inputs/physics.npz": args.physics / "physics.npz",282"inputs/physics-metadata.json": args.physics / "metadata.json",283**LIB.physics_preparation_sources(args.physics, metadata),284}285if (286digest(args.anatomy / "source-license-manifest.json")287!= anatomy["implementation"]["source_license_manifest_sha256"]288):289raise ValueError("anatomy rights record changed")290with RunRecorder(291private_output(args.output, repository_root(__file__)),292configuration=public,293sources=sources(args.config, extra),294) as run:295LIB.write_array(run, "evaluation/reference-fractions.npy", fields)296run.write_json("fitting.json", public)297forwards = [298LIB.Forward(grid, geometry, fields, physics, spec, samples, args.device)299for samples in (config["fitting_samples"], config["generating_samples"])300]301means_by_role = {}302checks = []303pixels = config["independent_pixels_rc"]304for role, records in rows.items():305means = []306for index, row in enumerate(records):307pose = pose_from(row)308fitting, generating = [forward.project(pose) for forward in forwards]309error = mean_error(fitting, generating)310require_sampling(error, config)311cpu = independent_means(fields, grid, geometry, pose, physics, pixels)312selected = np.array(313[[generating[c, r, k] for r, k in pixels] for c in range(generating.shape[0])]314)315cpu_error = mean_error(selected, cpu)316require_sampling(cpu_error, config)317checks.append(318{"role": role, "view": index, "quadrature": error, "independent_cpu": cpu_error}319)320means.append(generating)321print(f"qualified {role} view {index + 1}/{len(records)}", flush=True)322means_by_role[role] = np.stack(means)323LIB.write_array(run, f"evaluation/{role}-means.npy", means_by_role[role])324run.write_json(325"qualification.json", {"passed": True, "checks": checks, "before_any_count_draw": True}326)327for rep in config["replicates"]:328for role_id, (role, means) in enumerate(means_by_role.items()):329counts, keys = poisson(means, config, phase, rep, role_id)330prefix = "fitting" if role == "fitting" else "evaluation"331LIB.write_array(run, f"{prefix}/rep{rep}-{role}-counts.npy", counts)332run.write_json(f"{prefix}/rep{rep}-{role}-noise.json", {"keys": keys})333run.write_json(334"observation-identity.json",335verify_observation_identity(args.output, config, args.development),336)337338339class SolveBudgetError(Exception):340"""An accepted state exhausted the prospectively declared soft solve budget."""341342343def make_solver(344public: dict[str, Any],345counts: np.ndarray,346physics: dict[str, Any],347spec: Any,348settings: MaterialReconstructionSettings,349device: str,350initial: np.ndarray | None = None,351) -> MaterialReconstruction:352grid, config = grid_from(public["grid"]), public["protocol"]353if initial is None:354initial = np.empty((2, *grid.shape), dtype=np.float32)355initial[0].fill(config["initial_water"])356initial[1].fill(config["initial_bone"])357geometry = DetectorGeometry(**{key: tuple(value) for key, value in public["geometry"].items()})358views = tuple(359MaterialReconstructionView(360geometry, pose_from(row), counts[i], physics["weights"], physics["response"]361)362for i, row in enumerate(public["trajectories"]["fitting"])363)364h = public["metric"]["matrix"]365return MaterialReconstruction(366grid=grid,367views=views,368coefficients=physics["mu_mm_inv"],369spectral_spec=spec,370initial_fractions=initial,371settings=settings,372samples_per_ray=config["fitting_samples"],373device=device,374material_metric=(h[0][0], h[0][1], h[1][1]),375)376377378def derivative_check(solver: MaterialReconstruction, config: dict[str, Any]) -> dict[str, Any]:379"""Feasible central differences check the complete fitting objective/VJP."""380original = solver.fractions_numpy().copy()381interior = np.empty_like(original)382interior[0].fill(0.65)383interior[1].fill(0.2)384solver.set_fractions(interior)385solver.evaluate(gradient=True)386gradient = solver.gradient.numpy().astype(np.float64)387checks = []388for seed in (7051, 7052, 7053):389direction = (390np.random.default_rng(seed).uniform(-0.1, 0.1, interior.shape).astype(np.float32)391)392analytic = float(gradient @ direction.ravel().astype(np.float64))393differences = []394for step in (0.02, 0.01, 0.005):395high = np.ascontiguousarray(interior + step * direction, dtype=np.float32)396low = np.ascontiguousarray(interior - step * direction, dtype=np.float32)397solver.set_fractions(high)398upper = solver.evaluate(gradient=False)399solver.set_fractions(low)400lower = solver.evaluate(gradient=False)401differences.append({"step": step, "finite_difference": (upper - lower) / (2 * step)})402error = min(abs(x["finite_difference"] - analytic) for x in differences)403limit = max(404config["directional_derivative_absolute_tolerance"],405abs(analytic) * config["directional_derivative_relative_tolerance"],406)407checks.append(408{409"seed": seed,410"analytic": analytic,411"differences": differences,412"minimum_error": error,413"limit": limit,414"passed": error <= limit,415}416)417solver.set_fractions(original)418if not all(row["passed"] for row in checks):419raise ValueError(f"full-objective directional derivative check failed: {checks}")420return {"passed": True, "checks": checks}421422423def independent_derivative_check(424public: dict[str, Any], counts: np.ndarray, physics: dict[str, Any], spec: Any, device: str425) -> dict[str, Any]:426"""Compare the CUDA VJP with exact CPU path integrals on fixed actual rays.427428The artificial interior field and directions are derivative test inputs,429never example anatomy or optimisation initialisation. Counts come only430from the fitting bundle. No withheld/reference data enter this check.431"""432config, grid = public["protocol"], grid_from(public["grid"])433geometry = DetectorGeometry(**{key: tuple(value) for key, value in public["geometry"].items()})434fields = np.empty((2, *grid.shape), dtype=np.float32)435fields[0].fill(0.65)436fields[1].fill(0.2)437selection = [438(view, row, col) for view in (0, 17, 34) for row, col in ((16, 16), (24, 24), (32, 32))439]440views = []441for view, row, col in selection:442pixel_origin = tuple(443geometry.origin_mm[i]444+ col * geometry.spacing_mm[0] * geometry.u[i]445+ row * geometry.spacing_mm[1] * geometry.v[i]446for i in range(3)447)448pixel_geometry = DetectorGeometry(449geometry.source_mm, pixel_origin, geometry.u, geometry.v, geometry.spacing_mm, (1, 1)450)451observed = np.ascontiguousarray(counts[view, :, row : row + 1, col : col + 1])452views.append(453MaterialReconstructionView(454pixel_geometry,455pose_from(public["trajectories"]["fitting"][view]),456observed,457physics["weights"],458physics["response"],459)460)461solver = MaterialReconstruction(462grid=grid,463views=tuple(views),464coefficients=physics["mu_mm_inv"],465spectral_spec=spec,466initial_fractions=fields,467settings=MaterialReconstructionSettings(iterations=1),468samples_per_ray=config["fitting_samples"],469device=device,470)471solver.evaluate(gradient=True)472gradient = solver.gradient.numpy().astype(np.float64)473coefficients = physics["mu_mm_inv"].astype(np.float64)474spectral_weights = physics["weights"].astype(np.float64) * physics["response"].astype(475np.float64476)477checks = []478for seed in (8041, 8042, 8043):479direction = np.random.default_rng(seed).uniform(-0.1, 0.1, fields.shape).astype(np.float32)480cpu = 0.0481for view, row, col in selection:482pose = pose_from(public["trajectories"]["fitting"][view])483paths = np.array(484[485integrate_sampled_field(486fields[m].ravel(), grid, geometry, pose, row, col, validate=False487)488for m in (0, 1)489]490)491direction_paths = np.array(492[493integrate_sampled_field(494direction[m].ravel(), grid, geometry, pose, row, col, validate=False495)496for m in (0, 1)497]498)499contributions = spectral_weights * np.exp(-(paths @ coefficients))500mean = contributions.sum(axis=1)501derivative = -(contributions @ (direction_paths @ coefficients))502cpu += float(np.sum((1.0 - counts[view, :, row, col] / mean) * derivative))503cuda = float(gradient @ direction.ravel().astype(np.float64))504limit = max(505config["directional_derivative_absolute_tolerance"],506abs(cpu) * config["directional_derivative_relative_tolerance"],507)508checks.append(509{510"seed": seed,511"cpu_exact_integral_derivative": cpu,512"cuda_sampled_derivative": cuda,513"absolute_error": abs(cuda - cpu),514"limit": limit,515"passed": abs(cuda - cpu) <= limit,516}517)518if not all(row["passed"] for row in checks):519raise ValueError(f"independent CPU derivative comparison failed: {checks}")520return {521"passed": True,522"selected_fitting_rays": selection,523"checks": checks,524"scope": "exact CPU trilinear integration plus analytic spectral Poisson derivative",525}526527528def prepare_metric(solver: MaterialReconstruction, run: RunRecorder, prefix: str) -> None:529"""Prepare an owned metric from this accepted field, outside a solve."""530from dpt.material_preconditioning import prepare_fisher_scaling531532scaling = prepare_fisher_scaling(solver)533solver.set_voxel_metric_scale(scaling.scale)534run.write_json(f"{prefix}preconditioning.json", scaling.diagnostics)535LIB.write_array(536run,537f"{prefix}voxel-metric-scale.npy",538solver.voxel_metric_scale.numpy().reshape(solver.grid.shape),539)540541542def solve_stages(543solver: MaterialReconstruction,544run: RunRecorder,545config: dict[str, Any],546iterations: int,547seconds: float,548refresh_steps: list[int],549) -> dict[str, Any]:550"""Keep one accepted trajectory/gate across prescribed metric refreshes.551552Each canonical solve starts fresh BB/inertial histories. Refreshes only553occur after a completed update block; no field or tolerance is changed.554All stages share one soft time budget and global checkpoint numbering.555"""556history: list[dict[str, Any]] = []557stages: list[dict[str, Any]] = []558started = time.perf_counter()559boundaries = [step for step in refresh_steps if step < iterations] + [iterations]560result: dict[str, Any] = {}561for stage, boundary in enumerate(boundaries):562offset = len(history)563if stage:564if time.perf_counter() - started >= seconds:565result["termination"] = "wall_time_budget"566break567prepare_metric(solver, run, f"stages/after-{offset:04d}/")568if time.perf_counter() - started >= seconds:569result["termination"] = "wall_time_budget"570break571solver.settings = replace(solver.settings, iterations=boundary - offset)572573def callback(574iteration: int, row: dict[str, Any], *, offset: int = offset, stage: int = stage575) -> None:576global_iteration = offset + iteration577record = {578**row,579"iteration": global_iteration,580"stage": stage,581"stage_iteration": iteration,582"wall_seconds": time.perf_counter() - started,583}584history.append(record)585run.write_json(f"history/accepted-{global_iteration:04d}.json", record)586if global_iteration in config["checkpoint_steps"]:587LIB.write_array(588run,589f"checkpoints/fields-{global_iteration:04d}.npy",590solver.fractions_numpy(),591)592if global_iteration % 25 == 0:593print(594f"accepted {global_iteration}: objective={row['objective']:.8g}, "595f"mapping_before={row['gradient_mapping_before']:.8g}",596flush=True,597)598if time.perf_counter() - started >= seconds:599raise SolveBudgetError600601try:602result = solver.solve(callback)603except SolveBudgetError:604result = {605"termination": "wall_time_budget",606"accepted_steps": len(history) - offset,607"history": history[offset:],608}609run.write_json(f"stages/stage-{stage:02d}.json", result)610stages.append(611{612"stage": stage,613"accepted_updates_before": offset,614"accepted_steps": result["accepted_steps"],615"termination": result["termination"],616"metric_refreshed": stage > 0,617"secant_and_inertial_histories": "fresh",618}619)620if result["termination"] != "iteration_budget":621break622# Failed trials remain in the corresponding stage record. The global623# trajectory contains accepted updates only, with no duplicate boundary.624result.update(625accepted_steps=len(history),626history=history,627stages=stages,628solve_seconds=time.perf_counter() - started,629)630return result631632633def fit(args: argparse.Namespace) -> None:634record = complete_record(args.acquisition)635public = checked_json(args.acquisition, "fitting.json", record)636config = public["protocol"]637pins = observation_pins(config)638if (639digest(args.config) != public["protocol_sha256"]640or args.replicate not in config["replicates"]641):642raise ValueError("protocol or replicate differs from frozen acquisition")643require_physics_identity(args.physics, public)644qualification = checked_json(args.acquisition, "qualification.json", record)645if not qualification["passed"]:646raise ValueError("acquisition qualification did not pass")647name = f"fitting/rep{args.replicate}-fitting-counts.npy"648counts = checked_array(args.acquisition, name, record)649if pins and not public["development"]:650identity = checked_json(args.acquisition, "observation-identity.json", record)651if (652identity["count_files_sha256"] != pins653or identity["matches_preserved_final_observations"] is not True654or identity["development"] is not False655or identity["generation_precision"] != "float32"656or record["output_sha256"][name] != pins[name]657):658raise ValueError("fitting requires the exact preserved final observations")659_, physics, spec = LIB.load_physics(args.physics)660spec = replace(spec, precision=config.get("calculation_precision", "float32"))661development = public["development"]662iterations = (663config["development_accepted_updates"]664if development665else config["maximum_accepted_updates"]666)667seconds = (668config["development_soft_solve_seconds"] if development else config["soft_solve_seconds"]669)670if args.development_seconds is not None:671if not development or not 0 < args.development_seconds <= config["soft_solve_seconds"]:672raise ValueError("a development time override must stay within the final budget")673seconds = args.development_seconds674if args.development_iterations is not None:675if (676not development677or not 0 < args.development_iterations <= config["maximum_accepted_updates"]678):679raise ValueError("a development update override must stay within the final budget")680iterations = args.development_iterations681step_selection = config.get("step_selection", "geometric")682acceleration = config.get("acceleration", "none")683preconditioner = config.get("preconditioner", "none")684if args.development_step_selection is not None:685if not development:686raise ValueError("a step-selection override is allowed only for development")687step_selection = args.development_step_selection688if args.development_acceleration is not None:689if not development:690raise ValueError("an acceleration override is allowed only for development")691acceleration = args.development_acceleration692if args.development_preconditioner is not None:693if not development:694raise ValueError("a preconditioner override is allowed only for development")695preconditioner = args.development_preconditioner696if preconditioner not in ("none", "fisher"):697raise ValueError("preconditioner must be none or fisher")698refresh_steps = config.get("preconditioner_refresh_after", [])699if (700not isinstance(refresh_steps, list)701or any(type(step) is not int or step <= 0 for step in refresh_steps)702or refresh_steps != sorted(set(refresh_steps))703or (refresh_steps and preconditioner != "fisher")704):705raise ValueError(706"metric refreshes require ordered unique positive steps and Fisher scaling"707)708settings = MaterialReconstructionSettings(709iterations=iterations,710initial_step=config["initial_step"],711mapping_step=config["mapping_step"],712regularisation_mm_inverse=config["regularisation_mm_inverse"],713step_selection=step_selection,714acceleration=acceleration,715)716extra = {717"inputs/acquisition-run.json": args.acquisition / "run.json",718"inputs/fitting.json": args.acquisition / "fitting.json",719"inputs/counts.npy": args.acquisition / name,720"inputs/qualification.json": args.acquisition / "qualification.json",721"inputs/physics.npz": args.physics / "physics.npz",722"inputs/physics-metadata.json": args.physics / "metadata.json",723}724if pins:725extra["inputs/observation-identity.json"] = args.acquisition / "observation-identity.json"726resumed = None727if args.resume_development_fit is not None:728if not development:729raise ValueError("checkpoint continuation is allowed only for development")730previous = complete_record(args.resume_development_fit)731if (732not previous["configuration"]["development"]733or previous["configuration"]["replicate"] != args.replicate734or previous["source_sha256"]["inputs/acquisition-run.json"]735!= digest(args.acquisition / "run.json")736):737raise ValueError("continuation must use the same development acquisition and replicate")738resumed = {739"fields": checked_array(args.resume_development_fit, "fields.npy", previous),740"gate": checked_json(args.resume_development_fit, "stationarity-gate.json", previous),741"result": checked_json(args.resume_development_fit, "solver-result.json", previous),742}743for name in ("run.json", "fields.npy", "stationarity-gate.json", "solver-result.json"):744extra[f"continuation/{name}"] = args.resume_development_fit / name745with RunRecorder(746private_output(args.output, repository_root(__file__)),747configuration={748"protocol": config,749"development": development,750"replicate": args.replicate,751"maximum_updates": iterations,752"soft_seconds": seconds,753"step_selection": step_selection,754"acceleration": acceleration,755"preconditioner": preconditioner,756"preconditioner_refresh_after": refresh_steps,757"fitting_reference_access": False,758"continuation": str(args.resume_development_fit) if resumed is not None else None,759"secant_history_restart": resumed is not None,760},761sources=sources(args.config, extra),762) as run:763run.write_json(764"independent-derivatives.json",765independent_derivative_check(public, counts, physics, spec, args.device),766)767solver = make_solver(public, counts, physics, spec, settings, args.device)768run.set_metadata(device=str(solver.device), device_name=solver.device.name)769run.write_json("derivatives.json", derivative_check(solver, config))770initial_objective = solver.evaluate(gradient=True)771initial_mapping, _ = solver.projected_trial(config["mapping_step"], use_metric=False)772gate = stationarity_gate(773initial_mapping,774config["mapping_step"],775config["relative_mapping_tolerance"],776config["maximum_mapped_fraction_displacement"],777)778continuation = None779if resumed is not None:780# Round-off in a repeated GPU reduction cannot alter the admitted781# original-start threshold. Keep the actual recorded gate exactly.782if not np.isclose(783gate["initial_mapping"], resumed["gate"]["initial_mapping"], rtol=1e-6784):785raise ValueError("continued solver does not reproduce the original start mapping")786gate = resumed["gate"]787solver.set_fractions(resumed["fields"])788continuation = {789"previous_accepted_steps": resumed["result"]["accepted_steps"],790"start_objective": solver.evaluate(gradient=True),791"secant_history": "fresh restart at the recorded accepted field",792"original_gate_retained": True,793}794solver.settings = replace(795settings,796gradient_mapping_tolerance=gate["absolute_threshold"],797relative_gradient_mapping_tolerance=0.0,798)799run.write_json("stationarity-gate.json", gate)800if preconditioner == "fisher":801prepare_metric(solver, run, "")802LIB.write_array(run, "checkpoints/fields-0000.npy", solver.fractions_numpy())803started = time.perf_counter()804result = solve_stages(solver, run, config, iterations, seconds, refresh_steps)805final_objective = solver.evaluate(gradient=True)806mapping, _ = solver.projected_trial(config["mapping_step"], use_metric=False)807result.update(808initial_objective=initial_objective,809initial_gradient_mapping=initial_mapping,810final_objective=final_objective,811stationarity=stationarity_result(mapping, gate),812solve_seconds=time.perf_counter() - started,813reference_or_withheld_data_access=False,814continuation=continuation,815)816LIB.write_array(run, "fields.npy", solver.fractions_numpy())817LIB.write_array(run, "gradient.npy", solver.gradient.numpy())818LIB.write_array(run, "fitting-predictions.npy", np.stack(solver.predictions_numpy()))819run.write_json("solver-result.json", result)820print(821json.dumps(822{823key: result[key]824for key in ("termination", "accepted_steps", "stationarity", "solve_seconds")825}826),827flush=True,828)829830831def evaluate(args: argparse.Namespace) -> dict[str, Any]:832acquisition = complete_record(args.acquisition)833fitting = complete_record(args.fit)834public = checked_json(args.acquisition, "fitting.json", acquisition)835config = public["protocol"]836if digest(args.config) != public["protocol_sha256"]:837raise ValueError("evaluation protocol changed")838# Freeze and verify actual accepted output before opening the assigned reference.839fields = checked_array(args.fit, "fields.npy", fitting)840result = checked_json(args.fit, "solver-result.json", fitting)841if fitting["source_sha256"]["inputs/acquisition-run.json"] != digest(842args.acquisition / "run.json"843):844raise ValueError("fit is not bound to this acquisition")845frozen = {846name: digest(args.fit / name)847for name in ("run.json", "fields.npy", "gradient.npy", "solver-result.json")848}849gradient = checked_array(args.fit, "gradient.npy", fitting)850cpu_mapping = independent_mapping(fields, gradient, config["mapping_step"])851if not np.isclose(cpu_mapping, result["stationarity"]["final_mapping"], rtol=1e-10, atol=1e-7):852raise ValueError("independent CPU projected mapping disagrees with the final CUDA state")853reference = checked_array(args.acquisition, "evaluation/reference-fractions.npy", acquisition)854grid = grid_from(public["grid"])855geometry = DetectorGeometry(**{key: tuple(value) for key, value in public["geometry"].items()})856require_physics_identity(args.physics, public)857_, physics, spec = LIB.load_physics(args.physics)858extra = {859"inputs/acquisition-run.json": args.acquisition / "run.json",860"inputs/fit-run.json": args.fit / "run.json",861"inputs/fields.npy": args.fit / "fields.npy",862"inputs/gradient.npy": args.fit / "gradient.npy",863"inputs/solver-result.json": args.fit / "solver-result.json",864"inputs/reference.npy": args.acquisition / "evaluation/reference-fractions.npy",865"inputs/physics.npz": args.physics / "physics.npz",866"inputs/physics-metadata.json": args.physics / "metadata.json",867}868with RunRecorder(869private_output(args.output, repository_root(__file__)),870configuration={"protocol": config, "accepted_output_freeze": frozen},871sources=sources(args.config, extra),872) as run:873forward = LIB.Forward(874grid, geometry, fields, physics, spec, config["generating_samples"], args.device875)876errors = fields.astype(np.float64) - reference.astype(np.float64)877rmse = np.sqrt(np.mean(errors**2, axis=(1, 2, 3)))878metrics = {879"material_rmse_water_bone": rmse.tolist(),880"stationarity": result["stationarity"],881"termination": result["termination"],882"accepted_updates": result["accepted_steps"],883"replicate": fitting["configuration"]["replicate"],884"development": public["development"],885"independent_cpu_mapping": cpu_mapping,886}887for role, rows in public["trajectories"].items():888predictions = np.stack([forward.project(pose_from(row)) for row in rows])889means = checked_array(args.acquisition, f"evaluation/{role}-means.npy", acquisition)890metrics[role] = mean_error(predictions, means)891LIB.write_array(run, f"{role}-predictions.npy", predictions)892checks = []893for index, row in enumerate(rows):894pixels = config["independent_pixels_rc"]895cpu = independent_means(fields, grid, geometry, pose_from(row), physics, pixels)896selected = np.array(897[898[predictions[index, c, r, k] for r, k in pixels]899for c in range(predictions.shape[1])900]901)902error = mean_error(selected, cpu)903require_sampling(error, config)904checks.append({"view": index, **error})905run.write_json(906f"{role}-independent-final-field.json", {"passed": True, "checks": checks}907)908domain_passed = bool(909np.isfinite(fields).all()910and np.all(fields >= 0)911and np.all(fields.astype(np.float64).sum(axis=0) <= 1 + 2**-24)912)913metrics["domain_passed"] = domain_passed914metrics["numerical_passed"] = bool(915domain_passed916and result["stationarity"]["passed"]917and np.all(rmse <= config["maximum_material_rmse"])918and metrics["withheld"]["rms_poisson_sd"]919<= config["maximum_withheld_generating_mean_error_rms_poisson_sd"]920)921metrics["accepted"] = metrics["numerical_passed"] and not public["development"]922metrics["scope"] = (923"Matched coarse CT-derived assigned composition; ideal primary-only physics, "924"not patient material validation"925)926run.write_json("evaluation.json", metrics)927LIB.write_array(run, "signed-material-error.npy", errors)928print(json.dumps(metrics), flush=True)929return metrics930931932# region book:worked-reconstruction-workflow933def run_worked_example(args: argparse.Namespace) -> dict[str, Any]:934"""Generate once, freeze both fits, then assess every prescribed replicate."""935config = json.loads(args.config.read_text())936if config["replicates"] != [0, 1]:937raise ValueError("the complete worked example requires both prescribed replicates [0, 1]")938with RunRecorder(939private_output(args.output, repository_root(__file__)),940configuration={"protocol": config, "development": args.development},941sources=sources(args.config, {}),942) as run:943acquisition = args.output / "acquisition"944acquisition_args = argparse.Namespace(**vars(args))945acquisition_args.output = acquisition946acquire(acquisition_args)947fits = {}948for replicate in config["replicates"]:949fit_args = argparse.Namespace(**vars(args))950fit_args.acquisition = acquisition951fit_args.replicate = replicate952fit_args.output = args.output / f"fit-rep{replicate}"953fit(fit_args)954fits[replicate] = fit_args.output955# No reference or withheld assessment is opened until both fits exist.956outcomes = []957for replicate in config["replicates"]:958evaluation_args = argparse.Namespace(**vars(args))959evaluation_args.acquisition = acquisition960evaluation_args.fit = fits[replicate]961evaluation_args.output = args.output / f"evaluation-rep{replicate}"962outcomes.append(evaluate(evaluation_args))963child_records = {964str(path.relative_to(args.output)): digest(path)965for path in sorted(args.output.glob("*/run.json"))966}967summary = {968"development": args.development,969"replicates": outcomes,970"numerical_passed": all(row["numerical_passed"] for row in outcomes),971"accepted": all(row["accepted"] for row in outcomes),972"all_fits_frozen_before_reference_evaluation": True,973"child_records_sha256": child_records,974}975run.write_json("summary.json", summary)976return summary977978979# endregion book:worked-reconstruction-workflow980981982def main() -> None:983parser = argparse.ArgumentParser(description=__doc__)984parser.add_argument(985"command", choices=("all", "acquire", "fit", "evaluate"), nargs="?", default="all"986)987parser.add_argument("--config", type=Path, default=Path(__file__).with_name("protocol-v3.json"))988bundle = (989Path(__file__).resolve().parents[2]990/ "public/generated/worked-examples/inputs/reconstruction"991)992parser.add_argument("--anatomy", type=Path, default=bundle / "anatomy")993parser.add_argument("--physics", type=Path, default=bundle / "physics")994parser.add_argument("--acquisition", type=Path)995parser.add_argument("--fit", type=Path)996parser.add_argument("--replicate", type=int, default=0)997parser.add_argument("--development", action="store_true")998parser.add_argument("--development-seconds", type=float)999parser.add_argument("--development-iterations", type=int)1000parser.add_argument("--development-step-selection", choices=("geometric", "bb"))1001parser.add_argument("--development-acceleration", choices=("none", "inertial"))1002parser.add_argument("--development-preconditioner", choices=("none", "fisher"))1003parser.add_argument("--resume-development-fit", type=Path)1004parser.add_argument("--output", type=Path, required=True)1005parser.add_argument("--device", default="cuda:0")1006args = parser.parse_args()1007required = {1008"all": (),1009"acquire": ("anatomy",),1010"fit": ("acquisition",),1011"evaluate": ("acquisition", "fit"),1012}1013for name in required[args.command]:1014if getattr(args, name) is None:1015parser.error(f"{args.command} requires --{name}")1016result = {"all": run_worked_example, "acquire": acquire, "fit": fit, "evaluate": evaluate}[1017args.command1018](args)1019if args.command in ("all", "evaluate") and not result["accepted"] and not result["development"]:1020parser.exit(10211,1022"The retained final outcomes did not pass scientific acceptance; "1023"inspect the recorded report.\n",1024)102510261027if __name__ == "__main__":1028main()1029