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}