← Examples/01 · The Five-Struct API
View source on GitHub

The Five-Struct API

Build a phantom, configure a scanner, run a simulation, reconstruct an image.

BasisSimulator.jl exposes its entire user surface through five structs:

StructWhat it is
PhantomThe object being scanned (mask + materials + voxel size)
ScannerThe CT hardware (geometry, detector, filtration)
CTProtocolThe acquisition (kVp, mA, views, rotation, collimation)
SimOptionsThe physics model (which effects to include)
ReconOptionsThe output grid (matrix size, XY FOV, optional Z extent)

This notebook walks through each one — building a Gammex Model 472 phantom and scanning it on a model of the GE Revolution Apex Elite. We'll finish by comparing a standard-dose acquisition against a low-dose one.

Notebook Setup

Activate docs/Project.toml (which has BasisSimulator from the local source tree + WasmMakie), then bring in our imports — one per cell — and finally detect a GPU backend.

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
using PlutoUI
using Markdown: @md_str
import BasisSimulator as BS
# import CairoMakie as Mke
import WasmMakie as Mke
using Statistics: std

Backend-agnostic device transfer: to_gpu()

BasisSimulator dispatches kernels on array type via AcceleratedKernels.jl, so the same source runs on Metal, CUDA, ROCm, oneAPI, or CPU. The only thing the user controls is which array type they hand the simulator.

The cell below probes for Metal → CUDA → AMDGPU in that order, picks the first one that's installed and functional on the host, and falls back to plain Array (CPU) otherwise. Returned as a one-liner: to_gpu(arr).

Probe-then-load, never auto-install

Base.locate_package(pkg_id) returns the package path if it's already installed somewhere on the load path, or nothing otherwise — without triggering a download or a load attempt. Only when a package is already present do we call Base.require(...) to actually load it. So the same notebook runs cleanly on a Mac with Metal globally installed, on an NVIDIA box with CUDA installed, on an AMD box with AMDGPU, or on a CPU-only CI runner with none of them — no extra dependencies forced into the project.

begin
    import GPUSelect
    AT = GPUSelect.Storage() # the backend array type, directly: MtlArray / CuArray / ROCArray / oneArray / Array
    to_gpu(x) = AT(x)
    GPU_BACKEND = (name = string(nameof(AT)),)
end

Backend detected: MtlArray

The Five Structs

01. Phantom

A Phantom is three pieces of data:

  1. Labeled mask — a 3D integer array. Each integer is a region label.

  2. Materials dict — maps each label to an XrayAttenuation.Material.

  3. Voxel size(dx, dy, dz) in cm.

The factory create_gammex_472(...) builds the standard Gammex Model 472 multi-energy CT phantom: a 33 cm body of solid water with calcium and iodine inserts at known concentrations (used as a clinical CT calibration standard).

phantom_cpu = BS.create_gammex_472(
    n_voxels = 512,    # in-plane resolution (sets phantom mask size)
    n_slices = 16,     # axial slices in the phantom
    fov_cm = 35.0,   # body diameter region
    z_cm = 1.0,    # axial extent
);

The mask is UInt8 of shape (512, 512, 16), and there are 27 materials in the dictionary.

Now we move the mask to the GPU once and rebuild the Phantom struct around it. Every subsequent forward projection runs on the device.

Phantom is GPU-aware

Phantom is parameterized on its mask array type. Passing a MtlArray / CuArray / ROCArray mask makes the whole forward-projection pipeline dispatch to GPU kernels — no other code changes needed.

phantom = BS.Phantom(
    to_gpu(phantom_cpu.mask),
    phantom_cpu.materials,
    phantom_cpu.voxel_size,
    phantom_cpu.origin,
    phantom_cpu.extent,
);
Dict{Symbol, Any}(:msg => "UndefVarError: `cgrad` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/01_five_struct_api.jl#==#02000005-0000-4000-8000-000000000001", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 35, :file => "01_five_struct_api.jl#==#02000005-0000-4000-8000-000000000001", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `cgrad` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/01_five_struct_api.jl#==#02000005-0000-4000-8000-000000000001:35 [inlined]")

02. Scanner

Scanner describes the hardware — properties that don't change between acquisitions: source-to-isocenter distance, detector geometry, focal spot, bowtie filter, scintillator material.

The values below model a GE Revolution Apex Elite (256-row, 0.625 mm isotropic detector pixels, curved Lumex array), verified against vendor specs and clinical data.

scanner = BS.Scanner(
    # Geometry (mm)
    source_to_isocenter = 625.6,
    source_to_detector = 1100.0,

    # Detector array
    detector_rows = 256,
    detector_cols = 834,
    detector_row_size = 0.625,
    detector_col_size = 0.6,

    # Focal spot
    focal_spot_width = 1.0,
    focal_spot_length = 1.0,
    target_angle = 10.0,

    # Filtration
    flat_filter_material = :aluminum,
    flat_filter_thickness = 2.5,
    bowtie_filter = :ge_revolution_large,

    # Scintillator
    detector_material = :lumex,
    detector_depth = 3.0,
    fill_factor_row = 0.9,
    fill_factor_col = 0.9,

    # DAS noise model.  Injected into the COUNTS before the log transform, where
    # a real DAS contributes it — so it propagates through reconstruction rather
    # than being pasted onto the HU volume afterwards.
    electronic_noise = 3500.0,  # e⁻ — clinical GE Apex Elite DAS readout noise
    detection_gain = 10.0,    # e⁻/keV
);

Scanner is hardware, not acquisition

Anything that varies per scan (kVp, mA, view count, rotation time) belongs in CTProtocol, not Scanner. Treating them as separate structs means you can sweep over protocols without re-specifying the scanner each time.

Bowtie filter

bowtie_filter = :ge_revolution_large selects a vendor-specific shaped aluminum filter that pre-attenuates the periphery of the beam. This flattens dose distribution across the patient and is essential for realistic noise modeling. The catalog of available bowties lives in src/source/bowtie_filter.jl.

03. CTProtocol

CTProtocol is the acquisition struct. We define two protocols — a standard-dose scan at 200 mA and a low-dose scan at 50 mA — both at 120 kVp. We'll reconstruct each and compare noise at the end.

protocol_standard = BS.CTProtocol(
    kVp = 120,
    mA = 200.0,
    views = 500,
    rotation_time = 1.0,
    collimation_mm = 5.0,                 # nominal beam width; axial cone guards are automatic
    additional_filters = [("Al", 4.5)],       # ~7 mm total Al (matches GE Apex inherent filtration)
);
protocol_lowdose = BS.CTProtocol(
    kVp = 120,
    mA = 50.0,                # 4× lower current → ~2× more pixel noise
    views = 500,
    rotation_time = 1.0,
    collimation_mm = 5.0,
    additional_filters = [("Al", 4.5)],
);

`collimation_mm` slices the detector down

Our scanner has 256 rows × 0.625 mm = 160 mm of axial coverage, but a typical clinical scan uses a 5 mm nominal beam width. BasisSimulator automatically acquires symmetric detector guard rows when needed to support the complete requested reconstruction cylinder at the edge of the XY FOV.

04. SimOptions

SimOptions is the fidelity dial: which physics effects to include in the forward model.

The :eict preset enables a clinically-realistic stack — quantum noise, beam-hardening, scatter convolution, focal-spot blur, bowtie filtration, heel effect, and detector efficiency. To study any single effect's contribution, toggle it off below.

sim_opts = BS.SimOptions(
    fidelity = :eict,
    seed = 1234,
    projector = :dd_fast,   # same anti-aliased DD physics, single-pass fused kernels

    # Override individual physics toggles by uncommenting:
    # use_scatter           = false,   # disable scatter convolution
    # use_heel_effect       = false,   # disable anode heel effect
    # use_focal_spot        = false,   # disable focal-spot blur
    # use_optical_crosstalk = false,   # disable detector optical crosstalk
    # use_lag               = false,   # disable scintillator afterglow
    # use_noise             = false,   # ideal sinogram only (no Poisson + Gaussian)
);

Two fidelity presets

  • :eict — energy-integrating CT (conventional). Beer-Lambert across the full source spectrum, scintillator detection, full physics stack.

  • :pcct — photon-counting CT. Adds CdTe charge transport, K-fluorescence, pulse pileup, and an MC-derived detector response matrix. Pair with a photon-counting Scanner (detector_type = :photon_counting).

Reproducibility

seed = 1234 controls the Poisson + Gaussian noise RNGs. Same seed + same SimOptions = bit-identical sinogram. Pass seed = nothing for a fresh random draw on every call.

05. ReconOptions

ReconOptions controls only the reconstructed grid: matrix size, XY FOV, and optional Z extent. The reconstruction algorithm and apodization filter belong to the reconstruction workspace constructor, where they are consumed.

recon_opts = let
    slice_thickness_mm = 0.625
    n_recon_slices = round(Int, 5.0 / slice_thickness_mm)   # collimation_mm / slice_thickness

    BS.ReconOptions(
        matrix_size = (512, 512, n_recon_slices),
        fov_cm = 35.0,
        z_cm = 0.5,
    )
end

Algorithm and filter selection

Choose analytic FDK/WFBP with create_fdk_recon_workspace(...; filter=...) or Hybrid IR with create_hir_recon_workspace(...; strength=..., filter=...). ReconOptions deliberately contains no inert algorithm, iteration, filter, or VMI fields.

Simulate & Reconstruct

01. Forward Project (Standard Dose)

Time to actually scan the phantom. Each "phase" of GPU work — forward projection, reconstruction — lives inside its own let ... end block. The shape of every cell that touches the device is the same:

output_cpu = let
    # 1. Allocate GPU workspace + run the kernel
    ws = BS.create_…_workspace(…)
    BS.simulate!(ws, …)              # or BS.reconstruct!(…)

    # 2. Copy results off the device (CPU `Array`)
    result = Array(ws.sinogram)

    # 3. Explicit GPU cleanup
    ws = nothing
    GC.gc(true)

    result
end

Why `let` + `GC.gc(true)` matters

Each EICT workspace holds spectrum-binned forward buffers, scatter kernels, bowtie air references, and a full sinogram on the device — easily several hundred MB per protocol. On a 16 GB unified-memory M-series Mac, running two protocols back-to-back without dropping the first workspace's GPU buffers will OOM the Pluto worker (you'll see Malt.TerminatedWorkerException).

The let ... end scopes every GPU binding to the block; setting ws = nothing drops the last reference; GC.gc(true) forces Julia to actually release the device memory (a regular gc() won't run a full sweep). The outer cell only holds the CPU output — tiny by comparison.

This is also why we never put intermediate diagnostic GPU arrays in the returned NamedTuple — only Array(...)-copied CPU data leaves the block.

sim_std = let
    @info "Simulating: 120 kVp / 200 mA (standard dose)…"
    ws = BS.create_eict_workspace(scanner, protocol_standard, sim_opts, recon_opts, phantom)
    BS.simulate!(ws, phantom, protocol_standard, sim_opts)

    # Copy off GPU before tearing down the workspace
    result = (sino = Array(ws.sinogram), geom = ws.geom)

    # Drop refs + force a real GC so device memory comes back
    ws = nothing
    GC.gc(true)

    result
end;
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, subtitle::String, xlabel::String, ylabel::String, titlesize::Int64, subtitlesize::Int64, xlabelsize::Int64, ylabelsize::Int64, xticklabelsize::Int64, yticklabelsize::Int64)\nThis method does not support all of the given keyword arguments (and may not support any).\n\n\e[0mClosest candidates are:\n\e[0m WasmMakie.Axis(\e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Vector{WasmMakie.LinesPlot}\e[39m, \e[91m::Vector{WasmMakie.ScatterPlot}\e[39m, \e[91m::Vector{WasmMakie.BarPlotData}\e[39m, \e[91m::Vector{WasmMakie.HeatmapPlot}\e[39m, \e[91m::Vector{WasmMakie.ImagePlot}\e[39m, \e[91m::Vector{WasmMakie.HVLines}\e[39m, \e[91m::Vector{WasmMakie.HVSpan}\e[39m, \e[91m::Vector{WasmMakie.ABLines}\e[39m, \e[91m::Vector{WasmMakie.SegmentsPlot}\e[39m, \e[91m::Vector{WasmMakie.FilledCurve}\e[39m, \e[91m::Vector{WasmMakie.BandPlot}\e[39m, \e[91m::Vector{WasmMakie.PolyPlot}\e[39m, \e[91m::Vector{WasmMakie.MeshPlot}\e[39m, \e[91m::Vector{Tuple{Int64, Int64}}\e[39m)\e[91m got unsupported keyword arguments \"title\", \"subtitle\", \"xlabel\", \"ylabel\", \"titlesize\", \"subtitlesize\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(\e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m)\e[91m got unsupported keyword arguments \"title\", \"subtitle\", \"xlabel\", \"ylabel\", \"titlesize\", \"subtitlesize\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(; title, xlabel, ylabel, subtitle, titlealign)\e[91m got unsupported keyword arguments \"titlesize\", \"subtitlesize\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39

02. Reconstruct (Standard Dose: FBP)

Same pattern: allocate an FDK workspace, run reconstruct!, copy the volume off the device, drop GPU references, GC. The output is in linear attenuation units (cm⁻¹), which we convert to Hounsfield Units using a polychromatic effective μ_water for our 120 kVp spectrum + Gammex 472 body (33 cm diameter chord through the center voxel).

BS.compute_polychromatic_μ_water resolves the bowtie-aware source spectrum (flat filter + protocol filters + bowtie all included), pre-hardens it through water_path_cm of solid water via Beer-Lambert (mimicking the hardening a center-of-phantom ray sees), then integrates μwater(E) against the hardened spectrum. The result matches what the FBP recon actually produces for solid water — so by construction the phantom-center voxel reads ≈ 0 HU, instead of ≈ −150 HU you'd get with a monoenergetic `getreferenceμwater(70.0)`.

Both protocols share the same kVp + filtration, so we compute the reference once and reuse it across §7 and §8.

μ_water_recon = let
    body_radius_cm = 16.5   # Gammex 472 body
    BS.compute_polychromatic_μ_water(
        sim_opts, protocol_standard;
        scanner = scanner,
        geom = sim_std.geom,
        water_path_cm = body_radius_cm * 2,   # full chord through center voxel
    )
end;
hu_std = let
    sino_gpu = to_gpu(sim_std.sino)
    matrix_size = recon_opts.matrix_size

    ws_fdk = BS.create_fdk_recon_workspace(sino_gpu, sim_std.geom, matrix_size)
    recon_μ = BS.reconstruct!(ws_fdk, sino_gpu, sim_std.geom)

    # μ → HU, copying off the GPU at the same time. The `Float32.(...)` cast
    # is intentional (not redundant): `BS.μ_to_HU` widens to Float64 due to
    # an internal `1000.0` literal; Float32 keeps the volume memory-light on
    # the 16 GB M-series workers.
    hu = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = μ_water_recon))

    # Drop every GPU ref before exiting
    ws_fdk = nothing
    sino_gpu = nothing
    recon_μ = nothing
    GC.gc(true)

    hu
end;

03. Repeat (Low Dose)

Same two let ... end blocks, only protocol_lowdose swapped in. Everything else (scanner, simopts, reconopts, phantom) is reused unchanged — that's the point of splitting the API into separate structs. And because each previous block already cleaned up its GPU memory, this protocol gets a fresh device with no leftover state.

sim_low = let
    @info "Simulating: 120 kVp / 50 mA (low dose)…"
    ws = BS.create_eict_workspace(scanner, protocol_lowdose, sim_opts, recon_opts, phantom)
    BS.simulate!(ws, phantom, protocol_lowdose, sim_opts)

    result = (sino = Array(ws.sinogram), geom = ws.geom)

    ws = nothing
    GC.gc(true)

    result
end;
hu_low = let
    sino_gpu = to_gpu(sim_low.sino)
    matrix_size = recon_opts.matrix_size

    ws_fdk = BS.create_fdk_recon_workspace(sino_gpu, sim_low.geom, matrix_size)
    recon_μ = BS.reconstruct!(ws_fdk, sino_gpu, sim_low.geom)

    # Same polychromatic μ_water as §7 (shared 120 kVp spectrum + Gammex body)
    hu = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = μ_water_recon))

    ws_fdk = nothing
    sino_gpu = nothing
    recon_μ = nothing
    GC.gc(true)

    hu
end;

04. Postprocessing: The Full Correction Pipeline

A raw FBP recon (hu_std, hu_low above) is what comes out of the FDK kernel with no clinical corrections. Real CT vendors apply a stack of corrections on top — the same stack we'll build here:

StageFunctionWhat it does
1.calibrate_bhc_waterPrecomputes per-column water poly→mono polynomials from the FULL detected spectrum — pure physics, zero tunables
2.apply_bhc_waterSinogram-domain water BHC — one polynomial pass before FBP, removes the poly bias
3.reconstruct! (FDK)Filtered back-projection on the corrected sinogram
5.to_hounsfield (with BHC μ_water)Convert μ → HU using the BHC model's calibrated reference (not the 70 keV NIST value)
6.measure_radial_cuppingQA metric (never applied): fitted residual cup + DC — both ≈ 0 after a correct BHC

Noise belongs in the counts, not the HU volume

Quantum (Poisson) and DAS/electronic noise are both injected by simulate! before the log transform — electronic noise via scanner.electronic_noise (electrons). add_system_noise_floor! adds white Gaussian noise to the reconstructed volume instead, which double-counts the quantum noise already present and, because it bypasses the reconstruction operator, makes every recon algorithm look equally noisy. Reserve it for effects that genuinely survive reconstruction (calibration drift, ring residuals).

Cupping is a QA signal

measure_radial_cupping is intentionally non-mutating. After bowtie-aware water BHC, the fitted residual cup and DC offset should both be small. A large value points to an upstream calibration or coverage problem; it is not something this notebook hides with a post-reconstruction correction.

a. Calibrate the BHC model

Both protocols use 120 kVp with the same filtration, so a single BHC model covers both. The calibration is a one-time spectrum fit — input is (energies, weights) from the resolved source spectrum, output is a TwoMaterialBHC polynomial model + the calibrated μ_water_ref (which becomes our reference for to_hounsfield).

bhc_calibration = let
    # KNOBLESS water BHC — pure physics, zero tunables, zero thresholds.
    # calibrate_bhc_water resolves the FULL detected spectrum per detector
    # column (tube × filters × bowtie × heel × η(E), exactly the factors this
    # sim_opts enabled) and precomputes the poly→mono water polynomial for
    # each column.  Nothing is fitted to the data being corrected.
    model = BS.calibrate_bhc_water(
        sim_opts, protocol_standard;
        scanner = scanner, geom = sim_std.geom,
    )

    (
        model = model,
        μ_water = model.μ_water_ref,
        ref_E_keV = model.reference_energy_keV,
    )
end;

Calibrated:

  • ref energy = 69.7 keV

  • lac water = 0.19321 cm⁻¹

b. Standard-Dose Corrected Pipeline

Same let ... end shape as §7, but with the current correction stack inline: detected-spectrum water BHC → FDK → HU, followed by non-mutating cupping QA. Noise is already present in the simulated detector counts. GPU buffers are dropped at the end exactly like before.

hu_std_corr = let
    matrix_size = recon_opts.matrix_size

    # 1. Sinogram-domain BHC (returns a CPU array)
    sino_gpu = to_gpu(sim_std.sino)
    sino_bhc = BS.apply_bhc_water(sino_gpu, bhc_calibration.model)
    sino_gpu = to_gpu(sino_bhc)

    # 2. FDK on the BHC-corrected sinogram
    ws_fdk = BS.create_fdk_recon_workspace(sino_gpu, sim_std.geom, matrix_size)
    recon_μ = BS.reconstruct!(ws_fdk, sino_gpu, sim_std.geom)

    # 3. (image-domain BHC removed — audit: it was a scaled self-subtraction
    #    that deflated dense-material HU, not an artifact correction)

    # 4. μ → HU using BHC's calibrated μ_water_ref
    hu = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = bhc_calibration.μ_water))

    # 5. DAS/electronic noise is injected in the counts domain by simulate!
    # (scanner.electronic_noise), not bolted onto the HU volume here.

    # 6. (radial cupping "correction" removed — measured to INJECT +8 HU on
    #    noisy data via its asymmetric fit window; cupping is a QA metric now,
    #    checked in the verification cell via measure_radial_cupping)

    # GPU cleanup
    ws_fdk = nothing; sino_gpu = nothing; recon_μ = nothing
    GC.gc(true)

    hu
end;

c. Low-Dose Corrected Pipeline

Identical to 9b — same detected-spectrum water BHC, recon, HU conversion, and QA — only the input sinogram (sim_low.sino) and dose level differ.

hu_low_corr = let
    matrix_size = recon_opts.matrix_size

    # 1. Sinogram-domain BHC
    sino_gpu = to_gpu(sim_low.sino)
    sino_bhc = BS.apply_bhc_water(sino_gpu, bhc_calibration.model)
    sino_gpu = to_gpu(sino_bhc)

    # 2. FDK
    ws_fdk = BS.create_fdk_recon_workspace(sino_gpu, sim_low.geom, matrix_size)
    recon_μ = BS.reconstruct!(ws_fdk, sino_gpu, sim_low.geom)

    # 3. (image-domain BHC removed — see the note in the standard-dose cell)

    # 4. μ → HU
    hu = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = bhc_calibration.μ_water))

    # 5. DAS/electronic noise comes from simulate! (counts domain).  Both scans
    # share sim_opts.seed, but the low-dose λ differs, so the realizations do too.

    # (cupping correction removed — QA-only; see verification cell)

    # GPU cleanup
    ws_fdk = nothing; sino_gpu = nothing; recon_μ = nothing
    GC.gc(true)

    hu
end;

Results

Compare Protocols (Before & After Correction)

The two reconstructions use identical physics, geometry, and reconstruction settings — only the tube current differs. The lower-dose scan should show visibly more noise (≈√4 = 2× higher pixel σ) but the same mean HU values inside each rod. Postprocessing flattens the radial cup and adds the system counts-domain electronic noise — visible mostly in the rim region.

Dict{Symbol, Any}(:msg => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/01_five_struct_api.jl#==#10000002-0000-4000-8000-000000000001", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 8, :file => "01_five_struct_api.jl#==#10000002-0000-4000-8000-000000000001", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/01_five_struct_api.jl#==#10000002-0000-4000-8000-000000000001:8 [inlined]")

Noise (σ in HU, 60×60 ROI at center of solid water):

ProtocolmAσ rawσ corrected
Standard dose20082.3f082.6f0
Low dose50186.1f0187.2f0

Raw σ ratio low/std = 2.26f0× (theory: 2.0× for 4× lower mA).

Scroll Through Slices

The cone beam means edge slices are reconstructed from obliquer rays than central ones. Slide through the corrected volumes and watch the ends — this is where usable-z limits and any residual cone behaviour show up first.

4
Dict{Symbol, Any}(:msg => "UndefVarError: `Label` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/01_five_struct_api.jl#==#12000001-0000-4000-8000-000000000004", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 8, :file => "01_five_struct_api.jl#==#12000001-0000-4000-8000-000000000004", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `Label` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/01_five_struct_api.jl#==#12000001-0000-4000-8000-000000000004:8 [inlined]")

Verification

Quantitative pass/fail against physics-derived expectations — no eyeballing. Per-rod theory is mono HU at the BHC reference energy from the same XrayAttenuation data that drove the simulation; tolerances cover partial volume + noise + the single-kVp limit: the knobless water BHC maps the water component exactly, and the remaining high-Z (Ca/iodine) spectral residual is physics a single-energy scan cannot resolve — dual-energy/VMI territory.

✅ NB01 VERIFICATION: PASS (5/5)

checkvalueexpectedpass
water mean HU (corrected, central slice, label 3)1.7[-6.0, 6.0]
noise ratio low/std (raw, water-only ROI)2.1[1.7, 2.3]
radial structure QA (worst slice; incl. rod-ring hardening)9.9[0.0, 12.0]
DC offset QA (worst slice)5.7[0.0, 6.0]
rods passing (theory ± tol AND dose-invariant)14.0[14, 14]

Water per slice (corrected) — central-ROI falloff = bug; peripheral-only = real cone divergence:

  • full body: Float32[1.7, 1.7, 1.6, 1.7, 1.7, 1.7, 1.7, 1.7]

  • central r<5 cm: Float32[6.9, 6.5, 6.6, 6.5, 6.7, 6.7, 6.9, 6.7]

Per-rod, CENTRAL slice — theory = mono HU @ 69.7 keV; raw = uncorrected recon (alignment sentinel):

label materialtheorycorrectedrawΔΔ%Δdosepass
10 Gammex 472 (50.0 mg/ml Calcium)175.0173.0248.0-2.0-1.2%-1.0
11 Gammex 472 (100.0 mg/ml Calcium)315.0297.0371.0-18.0-5.6%1.0
12 Gammex 472 (200.0 mg/ml Calcium)615.0575.0649.0-40.0-6.6%-7.0
13 Gammex 472 (300.0 mg/ml Calcium)905.0828.0901.0-77.0-8.5%-10.0
14 Gammex 472 (400.0 mg/ml Calcium)1194.01071.01142.0-123.0-10.3%-12.0
15 Gammex 472 (500.0 mg/ml Calcium)1484.01314.01382.0-170.0-11.5%-19.0
16 Gammex 472 (600.0 mg/ml Calcium)1788.01572.01639.0-216.0-12.1%-25.0
20 Gammex 472 (2.0 mg/ml Iodine)56.053.0100.0-3.0-5.1%-1.0
21 Gammex 472 (2.5 mg/ml Iodine)68.063.0111.0-6.0-8.1%-1.0
22 Gammex 472 (5.0 mg/ml Iodine)131.0120.0168.0-11.0-8.6%-3.0
23 Gammex 472 (7.5 mg/ml Iodine)194.0169.0217.0-25.0-12.8%-1.0
24 Gammex 472 (10.0 mg/ml Iodine)256.0225.0273.0-31.0-12.0%-3.0
25 Gammex 472 (15.0 mg/ml Iodine)384.0331.0379.0-53.0-13.7%-4.0
26 Gammex 472 (20.0 mg/ml Iodine)524.0451.0498.0-72.0-13.8%-4.0

Summary

This notebook walked the entire BasisSimulator.jl user surface end to end:

  • Five structsPhantom, Scanner, CTProtocol, SimOptions, ReconOptions — each owning a clearly bounded slice of the simulation contract (object, hardware, acquisition, physics, output).

  • Two let ... end blocks per scan — one for simulate!, one for reconstruct! — each ending with ws = nothing; GC.gc(true) so GPU buffers actually come back. The Pluto worker stays comfortably under memory pressure even with two protocols back-to-back.

  • A clinical-grade postprocessing pipelinecalibrate_bhc_waterapply_bhc_water (knobless sinogram BHC, full detected spectrum) → FDK → to_hounsfield (with the BHC model's own μ_water_ref); quantum + DAS noise arrive in the counts domain from simulate!, and cupping/DC is measured, never applied (measure_radial_cupping).

  • Backend-agnostic GPU dispatchGPUSelect.Storage() resolves at startup, so the same notebook runs on Metal, CUDA, ROCm, oneAPI, or pure CPU with no notebook-level changes.

Every reconstruction in the rest of the docs (PCCT, Hybrid IR, dual-kVp VMI, …) reuses this same pattern — workspace in a let, kernel call, copy CPU result, nothing + GC.gc(true), return.