← Examples/09 · Siemens Force UFC Dual-Source VMI
View source on GitHub
begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
using Markdown: @md_str, Markdown
using Statistics: mean, std, quantile

09 · Siemens SOMATOM Force · UFC MC LUT · Dual-Source VMI

Standalone validation of a new EICT Monte-Carlo detector-response LUT for the Siemens UFC (Ultra-Fast Ceramic, Gd₂O₂S:Pr,Ce) scintillator on the SOMATOM Force third-generation dual-source scanner — before it is promoted into src/ alongside the GE Gemstone MC LUT.

One dual-source DE acquisition feeds both outputs — exactly like a PCCT scan can be read out as a routine-looking image or as spectral results:

UFC MC η(E) LUT  (Khodajou-Chokami MC, 1–140 keV)
        │ via Scanner(detector_material = :ufc)  (src MC-LUT pathway)
        ▼
Simulate 100 kVp (tube A) ──┬─→ POLY: per-tube η-aware BHC → FDK → HU
Simulate Sn140 kVp (tube B)─┘         → Siemens-style mixed image M_w
        │                               (water-HU validation, §7)
        ▼
   Cong Decomp (raw sinograms) → FBP × 2
        → cov-ACNR → z-median → VMI 50/70/100/140 → Mono+
        → Per-Rod Measured vs Theoretical Regression          (§8–11)

Single-energy vs dual-energy on the Force

The Force is not inherently spectral: routine protocols run both tubes at the same kVp (dual source buys temporal resolution and power, not spectra) and DE is a selectable mode. But within a DE acquisition there is no third "plain" scan — the routine-equivalent grayscale output is the mixed image, a weighted blend of the low-kV and high-kV reconstructions. This notebook models the DE acquisition and derives both readouts from it.

The UFC LUT is src-proper (sister pathway to Gemstone)

src/detector/detector_efficiency.jl ships UFC_MC_EFFICIENCY_LUT, get_ufc_mc_efficiency(E), and the detector_efficiency_ufc() factory; build_physics_config dispatches detector_material = :ufc to it (exactly as :lumex dispatches to Gemstone). So this notebook simply sets Scanner(detector_material = :ufc) + SimOptions(use_detector_efficiency = true) and the EICT forward model weights every energy bin by w(E) · η_UFC(E) · exp(-∫μ dl). The Cong basis and the BHC calibration resolve the same η-folded spectrum via resolve_source_spectrum_full, so the forward and inverse spectral models match exactly. (This notebook originally validated the LUT standalone via spectrum_override before the src promotion — the two paths are algebraically identical.)

Notebook Setup

Activate the shared docs environment, load the simulator and plotting stack, detect the available compute backend, and build the notebook table of contents.

import BasisSimulator as BS
# import CairoMakie as Mke
import WasmMakie as Mke
import PlutoUI
begin
    import GPUSelect
    AT = GPUSelect.Storage()     # the backend array type, directly: MtlArray / CuArray / ROCArray
    to_gpu(x) = AT(x)
    GPU_BACKEND = (name = string(nameof(AT)),)
end

Backend detected: MtlArray

1. The UFC Monte-Carlo Efficiency LUT

Per-energy absorbed fraction η(E) for the Siemens UFC Gd₂O₂S scintillator, from a full Monte-Carlo transport simulation of the SOMATOM Force StellarInfinity detector.

Provenance: Hamidreza Khodajou-Chokami, PhD (UC Irvine Medical Imaging Laboratory), efficiency_results.csv, received 2026-06-08. The 140 values (1-keV grid, 1–140 keV) live verbatim in src as BS.UFC_MC_EFFICIENCY_LUT (src/detector/detector_efficiency.jl), the sister of BS.GEMSTONE_MC_EFFICIENCY_LUT; an archival copy of the CSV is at docs/notebooks/data/ufc_mc_efficiency_v1.csv (gitignored).

Physics signatures (same class of MC-only features the Gemstone LUT captures — Beer-Lambert cannot model these):

  1. Gd K-edge fluorescence escape at 50.24 keV: η drops 0.969 → 0.741 between 50 and 51 keV. Just above the K-edge, photoabsorption produces Gd Kα fluorescence (~43 keV) that escapes the thin crystal, so the deposited fraction falls even though attenuation rises. Beer-Lambert would predict the opposite jump.

  2. Gd L-edge structure near 7–8 keV (L₃ 7.24 / L₂ 7.93 / L₁ 8.38 keV): the small dip at 8 keV.

  3. Gradual high-energy roll-off (0.897 at 100 keV → 0.816 at 140 keV) from primary transmission + Compton escape.

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

2. Phantom: Gammex Model 472

phantom_cpu = BS.create_gammex_472(
    n_voxels = 512,
    n_slices = 16,
    fov_cm = 35.0,
    z_cm = 1.0,
);
phantom = BS.Phantom(
    to_gpu(phantom_cpu.mask),
    phantom_cpu.materials,
    phantom_cpu.voxel_size,
    phantom_cpu.origin,
    phantom_cpu.extent,
);

3. Scanner: Siemens SOMATOM Force

Third-generation dual-source: two Vectron tubes + two StellarInfinity UFC detectors at 95° in the same gantry. Spec sheet (sources below; the geometry was measured from an actual clinical Force by Wang et al.):

ParameterValueSource
Source–isocenter (SID)595.0 mmWang et al. 2021, arXiv:2001.09471 §IV
Source–detector (SDD)1085.6 mmWang et al. 2021
Tube A–B angular offset95° (z-offset 0.88 mm)Siemens Force whitepaper; Wang et al.
Detector A channels920 (50 cm FOV)Wang et al.; whitepaper §1.2
Detector B channels640 (35.5 cm FOV)Wang et al.; Flohr/Schmidt DE-DSCT chapter
Rows96 × 0.6 mm at iso (57.6 mm z-coverage)whitepaper §1.2
Column pitch at iso0.054°/ch ≈ 0.561 mmWang et al. (1.023 mm at detector / 1.825× mag)
ScintillatorUFC Gd₂O₂S:Pr,Ce, ρ ≈ 7.34 g/cm³Siemens UFC page; Rossner & Grabmaier 1991
Tube (Vectron)2 × 120 kW, 70–150 kVp / 10 kV steps, ≤1300 mAwhitepaper §1.1; datasheet
Focal spots0.4×0.5 mm² smallest; ~0.8 / 1.2 mm nominalwhitepaper; Duan AAPM 2014
Tin filter0.6 mm Sn on the high-kV tube (SnX modes)Faby et al. Siemens DECT white paper; PMC12745887
Rotation0.25 s min (0.5 s routine)whitepaper
Projections1160 / rotation per focal-spot positionFlohr et al. Med Phys 2005 (Siemens family figure)

Documented modeling assumptions (no public source exists)

  • Anode angle: Vectron's angle is unpublished → IPEM spectrum (typical 7–9° CT anode).

  • Flat filtration: unpublished → 3.0 mm Al + 0.9 mm Ti, the same Vectron-family stack this repo already uses for the Naeotom Alpha (nb08). The 0.6 mm Sn is added on tube B only.

  • Bowtie: Siemens body bowtie shape is unpublished → CatSim large-body profile as stand-in (same convention as nb04/nb08).

  • Scintillator thickness 1.4 mm / fill factor 0.9: proprietary; thickness is inert here (η comes from the MC LUT, and the Beer-Lambert fallback is not used), fill factor auto-cancels in the air-scan calibration.

  • Electronic noise = 0: Stellar's TrueSignal ASIC has no published absolute noise figure; its design point is "electronic noise negligible vs quantum noise" (Duan et al. AJR 2013).

Dual source → two co-registered scans

Exactly like nb03 models GE rapid-kVp switching as two sequential scans, the Force's two tubes are modeled as two Scanner + CTProtocol configs run back-to-back on identical detector geometry:

  • Detector arc: tube B gets detector-A's 920-channel arc so the (low, high) sinogram pair is per-ray co-registered for the Cong solver — physically defensible because the 33 cm Gammex body fits inside detector B's real 35.5 cm FOV, so no ray we use would be missing on the real detector B.

  • 95° in-plane tube offset: both modeled scans run a full axial rotation on the same angle grid, which is exactly what the clinical rebinning produces when it aligns the B data onto the A grid — a constant angular offset has no effect on a full-rotation axial scan.

  • 0.88 mm tube-B z-offset: modeled explicitly in §6 by shifting the phantom origin −0.88 mm in z for the tube-B scan (tube B images a z-shifted slab during the same rotation). For the z-invariant Gammex 472 this is an exact no-op, but the mechanism is in place so z-varying phantoms (XCAT, QRM) inherit the real misalignment.

  • DE-mode collimation: the Force reads out 128 × 0.6 mm in DE mode; we use 4.8 mm (8 × 0.6 mm) — the thin-collimation equivalent that fits the 1 cm Gammex z-extent, same convention as nb03's 5 mm.

# Tube/detector A geometry — shared by both modeled tubes (see md above).
scanner = BS.Scanner(
    source_to_isocenter = 595.0,
    source_to_detector = 1085.6,

    detector_rows = 96,
    detector_cols = 920,
    detector_row_size = 0.6,
    detector_col_size = 0.561,

    focal_spot_width = 0.8,
    focal_spot_length = 1.2,
    target_angle = 8.0,

    flat_filter_material = :aluminum,
    flat_filter_thickness = 3.0,
    bowtie_filter = :large_body,

    detector_material = :ufc,    # free-form tag; η comes from the MC LUT below
    detector_depth = 1.4,
    fill_factor_row = 0.9,
    fill_factor_col = 0.9,

    electronic_noise = 0,
    detection_gain = 10.0,
);

4. Protocols: One Dual-Source DE Acquisition

The Force's clinical abdomen DE pairs are x/Sn150 (x ∈ 70–100 kVp, 0.6 mm Sn on tube B). The bundled IPEM spectra top out at 140 kVp, so this notebook runs the 100 / Sn140 pair — the same pair the second-generation Definition Flash ran clinically (with 0.4 mm Sn; we keep the Force's 0.6 mm). Mean-energy separation is within ~2 keV of the 100/Sn150 target.

Tube currents follow the published Force abdomen 100/Sn150 reference (190 mAs A / 95 mAs B at 0.5 s → 2:1). This single acquisition feeds both the §7 poly/mixed readout and the §8+ VMI readout — no separate plain scan exists on the real scanner in DE mode.

TubekVpFiltersmAviewsrotation
A (low)1003 Al + 0.9 Ti38011600.5 s
B (high)1403 Al + 0.9 Ti + 0.6 Sn19011600.5 s
protocol_low = BS.CTProtocol(
    kVp = 100,
    mA = 380.0,
    views = 1160,
    rotation_time = 0.5,
    collimation_mm = 4.8,    # nominal beam width; axial cone guards are automatic
    anode_angle = 8,
    additional_filters = [("Ti", 0.9)],
);
protocol_high = BS.CTProtocol(
    kVp = 140,
    mA = 190.0,
    views = 1160,
    rotation_time = 0.5,
    collimation_mm = 4.8,
    anode_angle = 8,
    additional_filters = [("Ti", 0.9), ("Sn", 0.6)],
);

5. SimOptions and ReconOptions

use_detector_efficiency = true (the :eict preset default) routes through the src UFC MC LUT: build_physics_config sees Scanner(detector_material = :ufc) and dispatches to detector_efficiency_ufc(), so the EICT forward model weights every energy by w(E) · η_UFC(E) and the detected flux (and therefore the Poisson noise level) automatically reflects the UFC absorption.

use_heel_effect = false keeps the forward spectral model exactly equal to the η-folded basis the Cong inversion uses (heel is a small row-direction effect; with 4.8 mm collimation at center it is negligible).

sim_opts = BS.SimOptions(
    fidelity = :eict,
    seed = 1234,
    use_heel_effect = false,   # exact forward/inverse spectral match
    projector = :dd_fast,      # same DD physics, single-pass fused kernels.
                               #  BHC (ufc_poly_recon) reads sim_opts.projector to match.
);
# Keep the intended centered 5 × 0.6 mm saved grid. The axial workspace
# automatically adds symmetric detector guard rows so peripheral voxels
# on both terminal slices retain measured cone-beam support.
recon_opts = BS.ReconOptions(
    matrix_size = (512, 512, 5),
    fov_cm = 35.0,
    z_cm = 0.30,
);
"""
    ufc_detected_spectrum(protocol) -> (e, w_eta)

Display helper: tube spectrum × flat filtration × protocol filters
(IPEM, absolute flux) with the src UFC MC η(E) folded in — what the
detector actually integrates (centered ray, no bowtie).
"""
function ufc_detected_spectrum(protocol)
    e, w = BS.resolve_source_spectrum_without_bowtie(
        sim_opts, protocol; scanner = scanner,
    )
    return e, Float64.(w) .* BS.get_ufc_mc_efficiency.(e)
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

6. Forward Project (one DE acquisition = two tube scans)

Each tube builds its own workspace (the UFC η enters via the src detector_efficiency pathway) and keeps only the noisy log-line-integral sinogram + geometry.

Tube B sees the phantom through a −0.88 mm z-shifted origin — the real detector-B z-offset (Wang et al. 2021). For the z-invariant Gammex this changes nothing, but the mechanism mirrors the physical scanner so z-varying phantoms inherit the misalignment (and any future z-rebinning step has something real to correct).

# Tube-B view of the phantom: origin shifted by the real −0.88 mm detector
# z-offset (no-op for the z-invariant Gammex; see §6 md).
phantom_b = BS.Phantom(
    phantom.mask,
    phantom.materials,
    phantom.voxel_size,
    (phantom.origin[1], phantom.origin[2], phantom.origin[3] - 0.088),
    phantom.extent,
);
sim_low = let
    @info "Simulating: 100 kVp / $(round(protocol_low.mA, digits = 1)) mA (tube A, UFC η folded)…"
    ws = BS.create_eict_workspace(
        scanner, protocol_low, sim_opts, recon_opts, phantom,
    )
    BS.simulate!(ws, phantom, protocol_low, sim_opts)
    I0_scalar = BS.compute_detector_I0(ws.geom, protocol_low, sum(ws.weights)) * Float64(ws.η_eff)
    air_ref = ws.bowtie_air_reference === nothing ? ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
        Array(ws.bowtie_air_reference)
    result = (sino = Array(ws.sinogram), geom = ws.geom,
        I0_ray = Float32.(I0_scalar .* Float64.(air_ref)))
    ws = nothing; GC.gc(true)
    result
end;
sim_high = let
    @info "Simulating: Sn140 kVp / $(round(protocol_high.mA, digits = 1)) mA (tube B, UFC η folded, z-offset −0.88 mm)…"
    ws = BS.create_eict_workspace(
        scanner, protocol_high, sim_opts, recon_opts, phantom_b,
    )
    BS.simulate!(ws, phantom_b, protocol_high, sim_opts)
    I0_scalar = BS.compute_detector_I0(ws.geom, protocol_high, sum(ws.weights)) * Float64(ws.η_eff)
    air_ref = ws.bowtie_air_reference === nothing ? ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
        Array(ws.bowtie_air_reference)
    result = (sino = Array(ws.sinogram), geom = ws.geom,
        I0_ray = Float32.(I0_scalar .* Float64.(air_ref)))
    ws = nothing; GC.gc(true)
    result
end;
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, titlesize::Int64, subtitlesize::Int64, xlabel::String, ylabel::String, 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\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"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\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"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[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m

7. Poly Readout: Per-Tube EICT Recon + Siemens Mixed Image

In DE mode the scanner's routine-equivalent grayscale output is the mixed image — a linear image-domain blend of the two per-tube reconstructions (Yu et al., Med Phys 2009: M = w·I_low + (1−w)·I_high; Eusemann et al., SPIE 2008). On Sn150-class pairs the clinical weight is w ≈ 0.5–0.6 (Lenga et al., Br J Radiol 2021).

So the poly validation of the UFC LUT runs the current nb01 correction stack per tube — η-aware water sinogram BHC → FDK → HU, with residual cupping measured as QA — then blends. If the η fold is right, solid water lands at ≈ 0 HU in both per-tube recons (and therefore in any blend).

η-aware BHC

The BHC water polynomial must see the same detected spectrum the forward model used. We resolve the bowtie-hardened per-column spectrum with the UFC η(E) already folded in (resolve_source_spectrum_full), and feed the low-level calibrate_bhc_water(e, w_col) — same per-column fit the high-level API performs, but with the UFC fold included.

"""
    ufc_bhc_calibration(protocol, geom)

η-aware per-tube BHC: bowtie-hardened per-column spectrum × UFC η(E) →
per-column two-material polynomial.  Returns `(model, μ_water, ref_E_keV)`.
"""
function ufc_bhc_calibration(protocol, geom)
    # resolve_source_spectrum_full folds bowtie AND the src UFC η(E)
    # (via the same build_physics_config the forward model used).
    e, ŵ = BS.resolve_source_spectrum_full(
        sim_opts, protocol; scanner = scanner, geom = geom,
    )
    e2, w_col = BS.bhc_spectrum_per_column(e, ŵ)          # [n_E, n_col]
    w_col_η = w_col

    # Single mono-equivalent target = mean energy of the η-folded mean spectrum
    w_mean = vec(sum(w_col_η; dims = 2)) ./ size(w_col_η, 2)
    ref_E = sum(e2 .* w_mean) / sum(w_mean)

    # KNOBLESS water BHC from the custom UFC-η per-column spectrum — zero
    # segmentation thresholds (the two-material bone pass is deprecated:
    # its 450–600 HU window misclassified dense iodine as bone).
    model = BS.calibrate_bhc_water(
        e2, w_col_η;
        reference_energy_keV = ref_E,
    )
    return (model = model, μ_water = model.μ_water_ref, ref_E_keV = model.reference_energy_keV)
end;
"""
    ufc_poly_recon(sino_cpu, geom, bhc) -> Array{Float32, 3}

Doctrine correction stack for one tube: knobless water sino-BHC → FDK → HU.
"""
function ufc_poly_recon(sino_cpu, geom, bhc)
    matrix_size = recon_opts.matrix_size

    sino_gpu = to_gpu(sino_cpu)
    # Knobless water BHC: one sinogram-domain pass, no recon round-trip.
    sino_bhc = BS.apply_bhc_water(sino_gpu, bhc.model)
    sino_gpu = sino_bhc

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

    # (image-domain BHC + applied cupping removed — deprecated; cupping is a
    #  QA metric via measure_radial_cupping)
    hu = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = bhc.μ_water))

    ws_fdk = nothing; sino_gpu = nothing; recon_μ = nothing
    GC.gc(true)
    return hu
end;
bhc_low = ufc_bhc_calibration(protocol_low, sim_low.geom);
bhc_high = ufc_bhc_calibration(protocol_high, sim_high.geom);

Calibrated (η-aware): tube A ref energy = 65.3 keV · lac water = 0.19835 cm⁻¹ — tube B ref energy = 98.6 keV · lac water = 0.1715 cm⁻¹

hu_tube = (
    low = ufc_poly_recon(sim_low.sino, sim_low.geom, bhc_low),
    high = ufc_poly_recon(sim_high.sino, sim_high.geom, bhc_high),
);
# Siemens linear mixed image: M = w·I_low + (1−w)·I_high (image domain,
# Yu 2009).  w = 0.5 is the common Sn150-pair default (0.3–0.7 clinical).
MIX_W_LOW = 0.5f0;
hu_mixed = MIX_W_LOW .* hu_tube.low .+ (1.0f0 - MIX_W_LOW) .* hu_tube.high;
poly_water_stats = let
    ERODE_PX = 12.0
    mask_2d_raw = phantom_cpu.mask[:, :, size(phantom_cpu.mask, 3) ÷ 2]
    sw_bool = BS.erode_mask_2d(
        mask_2d_raw .== UInt8(BS.REGION_SOLID_WATER); erode_px = ERODE_PX,
    )
    sw_idx = findall(sw_bool)
    n_z = size(hu_mixed, 3)

    function _stats(vol)
        vals = Float64[Float64(vol[ci, z]) for z in 1:n_z, ci in sw_idx]
        (mean = mean(vals), std = std(vals), n = length(vals))
    end
    stats = (
        low = _stats(hu_tube.low),
        high = _stats(hu_tube.high),
        mixed = _stats(hu_mixed),
    )
    for (tag, s) in pairs(stats)
        @info "[poly · UFC] $(tag) SW ROI: ⟨HU⟩ = $(round(s.mean, digits = 2)), σ = $(round(s.std, digits = 2)) HU (n = $(s.n))"
    end
    (stats..., mask_2d = collect(sw_bool))
end;
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/09_siemens_force_ufc_dual_source_vmi.jl#==#09000008-0000-4000-8000-000000000040", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 14, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#09000008-0000-4000-8000-000000000040", :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/09_siemens_force_ufc_dual_source_vmi.jl#==#09000008-0000-4000-8000-000000000040:14 [inlined]")

Poly Water Values

Water-HU validation of the UFC LUT in the plain EICT chain, before any spectral decomposition: solid-water ⟨HU⟩ ± σ for each per-tube recon and the mixed image. All three should cluster at ≈ 0 HU.

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/09_siemens_force_ufc_dual_source_vmi.jl#==#09000008-0000-4000-8000-000000000060", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 18, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#09000008-0000-4000-8000-000000000060", :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/09_siemens_force_ufc_dual_source_vmi.jl#==#09000008-0000-4000-8000-000000000060:18 [inlined]")

8. Projection-Domain Material Decomposition (Cong)

Per-ray Cong univariate solver on the polychromatic transmission integral, iodine + water material basis, running on the raw noisy sinograms (no projection-domain denoising — the anti-correlated basis noise is handled by cov-ACNR after FBP). The per-ray spectral weights are source × flat filters × bowtie × UFC η(E) — the identical model the forward projector applied, because resolve_source_spectrum_full builds ŵ from the same build_physics_config (and therefore the same src UFC LUT) that simulate! used.

material_basis = let
    # Per-ray effective spectrum: source × filters × bowtie × src UFC η(E),
    # normalized per ray (Σ_E ŵ = 1) — the nb07 pattern.
    e_L, ŵ_L = BS.resolve_source_spectrum_full(
        sim_opts, protocol_low; scanner = scanner, geom = sim_low.geom,
        diagnostic = true, label = "low·UFC",
    )
    e_H, ŵ_H = BS.resolve_source_spectrum_full(
        sim_opts, protocol_high; scanner = scanner, geom = sim_high.geom,
        diagnostic = true, label = "high·UFC",
    )

    iodine_mat = BS.XA.Elements.Iodine
    water_mat = BS.XA.Materials.water

    p_L = Float32[Float32(BS.compute_mass_μ_at_energy(iodine_mat, Float64(E))) for E in e_L]
    q_L = Float32[Float32(BS.compute_mass_μ_at_energy(water_mat, Float64(E))) for E in e_L]
    p_H = Float32[Float32(BS.compute_mass_μ_at_energy(iodine_mat, Float64(E))) for E in e_H]
    q_H = Float32[Float32(BS.compute_mass_μ_at_energy(water_mat, Float64(E))) for E in e_H]

    (
        ŵ_L = ŵ_L, p_L = p_L, q_L = q_L,
        ŵ_H = ŵ_H, p_H = p_H, q_H = q_H,
    )
end;
sino_basis = let
    # First-order log-Poisson DEBIAS (deterministic; matches nb03).
    debias(p, I0_ray) = begin
        out = Float32.(p)
        nc, nr, nv = size(out)
        for v in 1:nv, r in 1:nr, c in 1:nc
            N = max(I0_ray[c, r] * exp(-out[c, r, v]), 1.0f0)
            out[c, r, v] -= 1.0f0 / (2.0f0 * N)
        end
        out
    end
    sino_low_gpu = to_gpu(debias(sim_low.sino, sim_low.I0_ray))
    sino_high_gpu = to_gpu(debias(sim_high.sino, sim_high.I0_ray))

    sino_y = similar(sino_low_gpu)   # iodine basis line integrals
    sino_c = similar(sino_low_gpu)   # water  basis line integrals
    fill!(sino_y, 0.0f0); fill!(sino_c, 0.0f0)

    cong_ws = BS.create_cong_workspace(sino_low_gpu, material_basis)
    BS.apply_cong!(
        cong_ws, sino_y, sino_c, sino_low_gpu, sino_high_gpu;
        water_basis = (a = 0.0f0, c = 1.0f0),
    )

    result = (
        sino_iodine = Array(sino_y),
        sino_water = Array(sino_c),
        geom = sim_low.geom,
    )
    sino_low_gpu = nothing; sino_high_gpu = nothing
    sino_y = nothing; sino_c = nothing; cong_ws = nothing
    GC.gc(true)
    result
end;
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, titlesize::Int64, subtitlesize::Int64, xlabel::String, ylabel::String, 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\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"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\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"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[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m

9. FBP × 2 + Kalender ACNR

Identical post-decomposition chain to nb03: two FDK passes (BS.SoftFilter()) → image-domain data-adaptive cov-ACNR (BS.apply_acnr_kalender!, per-pixel regression, zero blur).

basis_volumes = let
    matrix_size = recon_opts.matrix_size
    geom = sino_basis.geom

    function _fbp(sino_cpu)
        sino_gpu = to_gpu(Float32.(sino_cpu))
        ws = BS.create_fdk_recon_workspace(
            sino_gpu, geom, matrix_size; filter = BS.SoftFilter(),
        )
        recon = Array(BS.reconstruct!(ws, sino_gpu, geom))
        ws = nothing; sino_gpu = nothing
        GC.gc(true)
        return Float32.(recon)
    end

    (
        vol_iodine_raw = _fbp(sino_basis.sino_iodine),
        vol_water_raw = _fbp(sino_basis.sino_water),
        geom = geom,
    )
end;
basis_acnr = let
    W = copy(basis_volumes.vol_water_raw)
    I = copy(basis_volumes.vol_iodine_raw)

    info = BS.apply_acnr_kalender!(W, I)
    @info "[ACNR · Kalender-1988 true ACNR] ρ_hp(W,I)=$(round(info.ρ_hp, digits = 3))"

    (vol_iodine_raw = I, vol_water_raw = W, geom = basis_volumes.geom)
end;
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(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000b-0000-4000-8000-000000000040", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 21, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000b-0000-4000-8000-000000000040", :func => "top-level scope", :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(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000b-0000-4000-8000-000000000040:21")

10. VMI Synthesis

Textbook 2-basis mix (McCollough 2015) at 50 / 70 / 100 / 140 keV:

μ(E)  = c_water(r) · (μ/ρ)_water(E) + c_iodine(r) · (μ/ρ)_iodine(E)
HU(E) = 1000 · (μ(E) − (μ/ρ)_water(E)) / (μ/ρ)_water(E)

The solid_water_basis diagnostic logs the basis-decomp residual bias as a Δ% between the SW-ROI synth μ_water and the textbook mono divisor.

solid_water_basis = let
    ERODE_PX = 12.0

    mask_2d_raw = phantom_cpu.mask[:, :, size(phantom_cpu.mask, 3) ÷ 2]
    sw_bool_raw = (mask_2d_raw .== UInt8(BS.REGION_SOLID_WATER))
    sw_bool = BS.erode_mask_2d(sw_bool_raw; erode_px = ERODE_PX)

    n_raw = count(sw_bool_raw); n_eroded = count(sw_bool)
    n_eroded == 0 && error(
        "solid_water_basis: deep erosion (σ = $(ERODE_PX) px) wiped out the SW " *
            "ROI (raw count = $(n_raw)).  Reduce erode_px or check phantom mask."
    )
    @info "solid_water_basis: SW mid-slice voxel count $(n_raw) → $(n_eroded) " *
        "after $(ERODE_PX)-px erosion"

    sw_idx = findall(sw_bool)
    n_z = size(basis_acnr.vol_water_raw, 3)
    function _mean(vol)
        s = 0.0; n = 0
        for z in 1:n_z, ci in sw_idx
            s += vol[ci, z]; n += 1
        end
        return s / n
    end

    c_w = Float64(_mean(basis_acnr.vol_water_raw))
    c_i = Float64(_mean(basis_acnr.vol_iodine_raw))
    @info "solid_water_basis: ⟨c_water⟩_SW = $(round(c_w, digits = 4)) g/cm³, " *
        "⟨c_iodine⟩_SW = $(round(c_i, digits = 6)) g/cm³"

    (
        c_water = c_w, c_iodine = c_i, n_voxels = length(sw_idx) * n_z,
        mask_2d = collect(sw_bool),
    )
end;
de_vmi_energies = [50.0, 70.0, 100.0, 140.0];
vmi_HU_final = let
    # synth_vmi_2basis expects c_iodine in mg/mL; basis maps are g/cm³ (= g/mL)
    c_iodine_mg_per_mL = basis_acnr.vol_iodine_raw .* 1000.0f0

    out = Dict{Float64, Array{Float32, 3}}()
    for E in de_vmi_energies
        μρ_w = BS.compute_mass_μ_at_energy(BS.XA.Materials.water, E)
        μρ_I = BS.compute_mass_μ_at_energy(BS.XA.Elements.Iodine, E)
        μ_water_anchor = solid_water_basis.c_water * μρ_w +
            solid_water_basis.c_iodine * μρ_I
        Δ_pct = 100.0 * (μ_water_anchor - μρ_w) / μρ_w
        @info "VMI synth @ $(Int(E)) keV: divisor = $(round(μρ_w, digits = 5)) cm⁻¹ " *
            "(mono μρ_water);  SW-ROI anchor = " *
            "$(round(μ_water_anchor, digits = 5)) → Δ = $(round(Δ_pct, digits = 2))%"

        out[E] = BS.synth_vmi_2basis(
            basis_acnr.vol_water_raw, c_iodine_mg_per_mL;
            energy_keV = E,
        )
    end
    out
end;
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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000c-0000-4000-8000-000000000040", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 13, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000c-0000-4000-8000-000000000040", :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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000c-0000-4000-8000-000000000040:13 [inlined]")
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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000d-0000-4000-8000-000000000030", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 13, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000d-0000-4000-8000-000000000030", :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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000d-0000-4000-8000-000000000030:13 [inlined]")

Results

Per-rod measured vs theoretical HU at 50 / 70 / 100 / 140 keV.

Methodology

  • Measured HU = mean over an 8-px-radius circular ROI at the rod centroid, broadcast across all z slices.

  • Theoretical HU = 1000 · (μ_r(E) − μ_water(E)) / μ_water(E) from BS.compute_μ_at_energy — pure physics, no fitting.

ROD_LABELS = (
    Ca = (UInt8(10), UInt8(11), UInt8(12), UInt8(13), UInt8(14), UInt8(15), UInt8(16)),
    I = (UInt8(20), UInt8(21), UInt8(22), UInt8(23), UInt8(24), UInt8(25), UInt8(26)),
);
ROD_NAMES = (
    Ca = ("50 mg/mL", "100 mg/mL", "200 mg/mL", "300 mg/mL", "400 mg/mL", "500 mg/mL", "600 mg/mL"),
    I = ("2.0 mg/mL", "2.5 mg/mL", "5.0 mg/mL", "7.5 mg/mL", "10.0 mg/mL", "15.0 mg/mL", "20.0 mg/mL"),
);
rod_data = let
    materials = phantom_cpu.materials
    mask_2d = phantom_cpu.mask[:, :, size(phantom_cpu.mask, 3) ÷ 2]
    nx, ny = size(mask_2d)
    ROI_RADIUS_PX = 8

    function rod_centroid(label::UInt8)
        idx = findall(==(label), mask_2d)
        isempty(idx) && error("rod_centroid: no voxels with label $label")
        cx = sum(ci -> Float64(ci[1]), idx) / length(idx)
        cy = sum(ci -> Float64(ci[2]), idx) / length(idx)
        return (cx, cy)
    end

    function rod_roi_mask(label::UInt8)
        cx, cy = rod_centroid(label)
        i_lo = max(1, floor(Int, cx - ROI_RADIUS_PX))
        i_hi = min(nx, ceil(Int, cx + ROI_RADIUS_PX))
        j_lo = max(1, floor(Int, cy - ROI_RADIUS_PX))
        j_hi = min(ny, ceil(Int, cy + ROI_RADIUS_PX))
        roi = CartesianIndex{2}[]
        r² = Float64(ROI_RADIUS_PX)^2
        for j in j_lo:j_hi, i in i_lo:i_hi
            ((i - cx)^2 + (j - cy)^2) ≤ r² && push!(roi, CartesianIndex(i, j))
        end
        return roi
    end

    rod_rois = Dict(
        lab => rod_roi_mask(lab)
            for lab in vcat(collect(ROD_LABELS.Ca), collect(ROD_LABELS.I))
    )

    μ_water_E = Dict(
        E => BS.compute_μ_at_energy(BS.XA.Materials.water, E)
            for E in de_vmi_energies
    )

    function theoretical_hu(material, E::Float64)
        μ = BS.compute_μ_at_energy(material, E)
        return 1000.0 * (μ - μ_water_E[E]) / μ_water_E[E]
    end

    function measured_hu(vmi_vol, label::UInt8)
        roi = rod_rois[label]
        s = 0.0; n = 0
        for z in 1:size(vmi_vol, 3), ci in roi
            s += vmi_vol[ci, z]; n += 1
        end
        return s / n
    end

    out = Dict{Symbol, NamedTuple}()
    for group in (:Ca, :I)
        labels = ROD_LABELS[group]
        n_rods = length(labels)
        n_E = length(de_vmi_energies)
        meas = zeros(Float64, n_rods, n_E)
        theo = zeros(Float64, n_rods, n_E)
        for (i, lab) in pairs(labels)
            mat = materials[Int(lab) + 1]   # mask_value + 1
            for (j, E) in pairs(de_vmi_energies)
                meas[i, j] = measured_hu(vmi_HU_final[E], lab)
                theo[i, j] = theoretical_hu(mat, E)
            end
        end
        out[group] = (
            labels = labels, names = ROD_NAMES[group],
            measured = meas, theoretical = theo,
        )
    end
    out
end;

Water ROI

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(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000e-0000-4000-8000-000000000050", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 10, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000e-0000-4000-8000-000000000050", :func => "top-level scope", :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(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000e-0000-4000-8000-000000000050:10")

Water-Region Noise

const WATER_NOISE_ROI_RADIUS_PX = 12;   # ≈8.2 mm at 0.683 mm/px
water_noise_roi = let
    nx_r, ny_r, nz_r = size(basis_acnr.vol_water_raw)
    cx = nx_r ÷ 2 + 1
    cy = ny_r ÷ 2 + 1

    roi_bool = falses(nx_r, ny_r)
    r² = Float64(WATER_NOISE_ROI_RADIUS_PX)^2
    @inbounds for j in 1:ny_r, i in 1:nx_r
        ((i - cx)^2 + (j - cy)^2) ≤ r² && (roi_bool[i, j] = true)
    end

    n_vox = count(roi_bool)
    @info "water_noise_roi: center = ($(cx), $(cy)), radius = $(WATER_NOISE_ROI_RADIUS_PX) px, " *
        "$(n_vox) vx × $(nz_r) z = $(n_vox * nz_r) total"

    (
        center_xy = (Float64(cx), Float64(cy)), mask_2d = roi_bool,
        n_voxels = n_vox, n_total = n_vox * nz_r,
    )
end;
vmi_noise_by_keV = let
    roi_idx = findall(water_noise_roi.mask_2d)
    nz_r = size(vmi_HU_final[70.0], 3)

    out = Dict{Float64, NamedTuple}()
    for E in de_vmi_energies
        vol = vmi_HU_final[E]
        vals = Float64[Float64(vol[ci, z]) for z in 1:nz_r, ci in roi_idx]
        μ = mean(vals); σ = std(vals)
        out[E] = (mean = μ, std = σ, n = length(vals))
        @info "water-region noise @ $(Int(E)) keV: ⟨HU⟩ = $(round(μ, digits = 2)),  σ = $(round(σ, digits = 2)) HU  (n = $(length(vals)))"
    end
    out
end;
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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000e-0000-4000-8000-000000000090", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 10, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000e-0000-4000-8000-000000000090", :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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000e-0000-4000-8000-000000000090:10 [inlined]")

Per-Rod Regression

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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000f-0000-4000-8000-000000000010", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 4, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000f-0000-4000-8000-000000000010", :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/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000f-0000-4000-8000-000000000010:4 [inlined]")

Linear Regression

Dict{Symbol, Any}(:msg => "UndefVarError: `RGBf` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000f-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 4, :file => "09_siemens_force_ufc_dual_source_vmi.jl#==#0900000f-0000-4000-8000-000000000030", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `RGBf` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl#==#0900000f-0000-4000-8000-000000000030:4")

Summary

UFC MC η(E) LUT (Khodajou-Chokami, Gd₂O₂S, 1–140 keV)
   → src pathway: Scanner(detector_material = :ufc) → detector_efficiency_ufc()
Simulate 100 kVp + Sn140 kVp   (one SOMATOM Force dual-source DE acquisition)
   ├─→ POLY: per-tube η-aware BHC → FDK → HU → mixed image M_w  (SW ≈ 0 HU ×3)
   └─→ VMI:  Cong Decomposition on raw sinograms  (iodine + water,
             bowtie + η_UFC per ray)
             → FBP × 2 → cov-ACNR → z-median
             → VMI 50/70/100/140 keV → Mono+
             → Per-Rod Measured vs Theoretical Regression

What validates the UFC LUT here:

  1. §7 poly: solid water ≈ 0 HU in the 100 kVp recon, the Sn140 recon, and the Siemens-style mixed image, each under its own η-aware BHC — the LUT's spectral shape is consistent with the detected sinogram on both very different tube spectra independently.

  2. §10–11 VMI: water-ROI bars ≈ 0 HU at every keV and tight per-rod measured-vs-theoretical overlays — the LUT survives the much harsher test of per-ray spectral inversion across the two detected spectra (100 kVp vs Sn140 kVp, separated by the 0.6 mm tin filter and sitting on opposite sides of the Gd K-edge fluorescence-escape cliff).

src status: the LUT lives in src/detector/detector_efficiency.jl next to GEMSTONE_MC_EFFICIENCY_LUT (UFC_MC_EFFICIENCY_LUT, get_ufc_mc_efficiency, detector_efficiency_ufc(), :ufc branches in compute_eid_efficiency_vector + build_physics_config, covered by test/detector.jl). This notebook consumes it directly via Scanner(detector_material = :ufc) — the original standalone spectrum_override validation path is retired.