Skip to main content

lindhard/tally/
psf_scan.rs

1//! Position-dependent point-spread function: the energy an electron beam
2//! deposits in a thin depth slab as its landing point is scanned across a
3//! laterally varying (voxel) target.
4//!
5//! # What is reported
6//!
7//! The radial profile of [`super::psf`] is the PSF of a beam on a laterally
8//! uniform stack. Over a target with a buried edge or line array the energy
9//! left in a film depends on *where* the beam lands, so this module runs the
10//! electron transport once per landing point `(y, z)` and reports, for the
11//! depth slab `depth_lo <= x < depth_hi`, the energy deposited per incident
12//! electron, with its standard error. Each run is a separate
13//! [`Transport::run_beam`] (parallel over histories, bit-identical at any
14//! thread count); a single run with a wide beam and spatial binning would mix
15//! the landing positions and lose the per-position response, so it is not
16//! used.
17//!
18//! The slab energy and its error are those of the
19//! [`RadialAccumulator`](super::psf::RadialAccumulator)
20//! total (`RadialProfile::total_ev` and `total_std_err_ev`): per-history
21//! slab totals `x_h` (the primary and all its secondaries), standard error
22//! `(N/(N-1) (Q - S²/N))^(1/2)` of the sum `S = Σ x_h`, `Q = Σ x_h²`, see
23//! [`super::psf`]. The energy per electron is `S / N` with error `SE / N`.
24//! The radial bins of that accumulator are about the origin of the target
25//! frame, not the landing point, and are not reported.
26//!
27//! # Determinism
28//!
29//! The landing points are looped sequentially in the order given. The seed of
30//! point `i` is derived from the base seed and `i` alone ([`point_seed`]), so
31//! it does not depend on the loop order or on the other points, and the report
32//! is bit-identical at any thread count.
33//!
34//! The mixing function is SplitMix64 (G. L. Steele Jr., D. Lea and
35//! C. H. Flood, "Fast splittable pseudorandom number generators", OOPSLA 2014,
36//! doi:10.1145/2660193.2660195, Fig. 15 / the `mix64` finaliser).
37
38use super::electron::{ElectronTallyConfig, ElectronTallyError, FullElectronTally};
39use super::hist::Binning;
40use super::psf::{LogRadialBinning, PsfConfig, PsfError};
41use crate::electron::transport::{BeamSpread, Primary, Transport, TransportError};
42use crate::geometry::Geometry;
43use serde::{Deserialize, Serialize};
44
45/// Errors of a landing-point scan.
46#[derive(Debug, Clone, PartialEq, thiserror::Error)]
47pub enum PsfScanError {
48    /// No landing point was given.
49    #[error("a PSF scan needs at least one landing point")]
50    NoPoints,
51    /// A landing coordinate is not finite.
52    #[error("landing point {index} is not finite: ({y}, {z}) m")]
53    NonFinitePoint {
54        /// Index of the point.
55        index: usize,
56        /// Its `y`, m.
57        y: f64,
58        /// Its `z`, m.
59        z: f64,
60    },
61    /// Zero histories per point or a zero chunk size.
62    #[error("histories per point and chunk size must be positive")]
63    NoHistories,
64    /// The depth slab is not finite with `lo < hi`.
65    #[error(transparent)]
66    Slab(#[from] PsfError),
67    /// The tally could not be built.
68    #[error(transparent)]
69    Tally(#[from] ElectronTallyError),
70    /// The transport rejected the primary or the beam.
71    #[error(transparent)]
72    Transport(#[from] TransportError),
73}
74
75/// What to scan: the depth slab, the landing points and the statistics.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct PsfScanConfig {
78    /// Lower depth of the slab, m.
79    pub depth_lo_m: f64,
80    /// Upper depth of the slab (excluded), m.
81    pub depth_hi_m: f64,
82    /// Landing points `(y, z)` on the front face, m, in report order. As for
83    /// [`BeamSpread::landing_m`] they are offsets from the geometry's entry
84    /// point (the centre of the front face of a voxel grid).
85    pub landing_m: Vec<[f64; 2]>,
86    /// Histories run at each point.
87    pub histories_per_point: u64,
88    /// Histories per tally chunk (see [`Transport::run_beam`]).
89    pub chunk_size: u64,
90    /// Base seed; the seed of point `i` is [`point_seed`]`(seed, i)`.
91    pub seed: u64,
92}
93
94/// The seed of landing point `index` from the base `seed`: SplitMix64 of
95/// `seed + (index + 1) · 0x9E3779B97F4A7C15`. A function of the two numbers
96/// only (see the module docs for the source).
97pub fn point_seed(seed: u64, index: u64) -> u64 {
98    let mut z = seed.wrapping_add(index.wrapping_add(1).wrapping_mul(0x9E37_79B9_7F4A_7C15));
99    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
100    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
101    z ^ (z >> 31)
102}
103
104/// The slab response at one landing point.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct PsfScanPoint {
107    /// Landing point `(y, z)`, m, as an offset from the geometry's entry point.
108    pub landing_m: [f64; 2],
109    /// Seed of this point's run.
110    pub seed: u64,
111    /// Histories run.
112    pub histories: u64,
113    /// Energy deposited in the slab per incident electron, eV.
114    pub energy_per_electron_ev: f64,
115    /// Its standard error, eV (0 for fewer than two histories).
116    pub std_err_ev: f64,
117}
118
119/// The energy deposited in a depth slab as a function of landing point.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct PsfScanReport {
122    /// Free-text provenance of the run (what target, beam and tables).
123    pub source: String,
124    /// Primary kinetic energy, eV.
125    pub primary_energy_ev: f64,
126    /// Lower depth of the slab, m.
127    pub depth_lo_m: f64,
128    /// Upper depth of the slab (excluded), m.
129    pub depth_hi_m: f64,
130    /// Base seed.
131    pub seed: u64,
132    /// One entry per landing point, in the order requested.
133    pub points: Vec<PsfScanPoint>,
134}
135
136impl PsfScanReport {
137    /// The report as CSV, one row per landing point. Columns: `y_m`, `z_m`,
138    /// `seed`, `histories`, `energy_per_electron_ev`, `std_err_ev`. Numbers
139    /// are written in Rust's shortest round-trip form.
140    pub fn to_csv(&self) -> String {
141        let mut s = String::from("y_m,z_m,seed,histories,energy_per_electron_ev,std_err_ev\n");
142        for p in &self.points {
143            s.push_str(&format!(
144                "{},{},{},{},{},{}\n",
145                p.landing_m[0],
146                p.landing_m[1],
147                p.seed,
148                p.histories,
149                p.energy_per_electron_ev,
150                p.std_err_ev
151            ));
152        }
153        s
154    }
155}
156
157/// Scan the landing point of `beam` over `scan.landing_m`: one
158/// [`Transport::run_beam`] per point, the point replacing `beam.landing_m`
159/// (the beam's profile and divergence are kept). `source` is recorded in the
160/// report.
161pub fn scan_landing_points<G: Geometry>(
162    transport: &Transport<G>,
163    primary: &Primary,
164    beam: &BeamSpread,
165    scan: &PsfScanConfig,
166    source: &str,
167) -> Result<PsfScanReport, PsfScanError> {
168    if scan.landing_m.is_empty() {
169        return Err(PsfScanError::NoPoints);
170    }
171    if scan.histories_per_point == 0 || scan.chunk_size == 0 {
172        return Err(PsfScanError::NoHistories);
173    }
174    if let Some((index, p)) = scan
175        .landing_m
176        .iter()
177        .enumerate()
178        .find(|(_, p)| !(p[0].is_finite() && p[1].is_finite()))
179    {
180        return Err(PsfScanError::NonFinitePoint {
181            index,
182            y: p[0],
183            z: p[1],
184        });
185    }
186    // One wide radial bin: only the slab total (with its per-history error)
187    // is used; the bins about the frame origin are not reported.
188    let psf = PsfConfig::new(
189        LogRadialBinning::new(1.0, 2.0, 1)?,
190        scan.depth_lo_m,
191        scan.depth_hi_m,
192    )?;
193    let mut tally_config = ElectronTallyConfig::new(
194        Binning::new(0.0, primary.energy_ev, 1).expect("one bin on a positive energy"),
195        Binning::new(0.0, std::f64::consts::FRAC_PI_2, 1).expect("one polar bin"),
196    );
197    tally_config.psf = Some(psf);
198    let proto = FullElectronTally::new(transport, tally_config)?;
199
200    let mut points = Vec::with_capacity(scan.landing_m.len());
201    for (i, &landing) in scan.landing_m.iter().enumerate() {
202        let seed = point_seed(scan.seed, i as u64);
203        let spread = BeamSpread {
204            landing_m: landing,
205            ..*beam
206        };
207        let run = transport.run_beam(
208            seed,
209            scan.histories_per_point,
210            scan.chunk_size,
211            primary,
212            &spread,
213            || proto.clone(),
214        )?;
215        let profile = run
216            .tally
217            .report()
218            .deposition
219            .psf
220            .expect("the PSF accumulator was configured");
221        let n = profile.histories as f64;
222        points.push(PsfScanPoint {
223            landing_m: landing,
224            seed,
225            histories: profile.histories,
226            energy_per_electron_ev: profile.total_ev / n,
227            std_err_ev: profile.total_std_err_ev / n,
228        });
229    }
230    Ok(PsfScanReport {
231        source: source.to_string(),
232        primary_energy_ev: primary.energy_ev,
233        depth_lo_m: scan.depth_lo_m,
234        depth_hi_m: scan.depth_hi_m,
235        seed: scan.seed,
236        points,
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn point_seed_depends_on_both_inputs_and_is_stable() {
246        assert_ne!(point_seed(1, 0), point_seed(1, 1));
247        assert_ne!(point_seed(1, 0), point_seed(2, 0));
248        assert_eq!(point_seed(7, 3), point_seed(7, 3));
249        // SplitMix64 reference stream from state 0: first output.
250        assert_eq!(point_seed(0, 0), 0xE220_A839_7B1D_CDAF);
251    }
252
253    #[test]
254    fn csv_has_one_row_per_point() {
255        let r = PsfScanReport {
256            source: "t".into(),
257            primary_energy_ev: 1.0,
258            depth_lo_m: 0.0,
259            depth_hi_m: 1.0,
260            seed: 1,
261            points: vec![
262                PsfScanPoint {
263                    landing_m: [1.0, 2.0],
264                    seed: 3,
265                    histories: 4,
266                    energy_per_electron_ev: 5.5,
267                    std_err_ev: 0.5,
268                };
269                2
270            ],
271        };
272        let csv = r.to_csv();
273        assert_eq!(csv.lines().count(), 3);
274        assert!(csv.starts_with("y_m,z_m,seed,histories,"));
275        assert_eq!(csv.lines().nth(1).unwrap(), "1,2,3,4,5.5,0.5");
276    }
277}