Skip to main content

lindhard/
exposure.rs

1//! Multi-shot exposure: the energy density deposited by a list of shots, from
2//! a fitted Gaussian-sum point-spread function ([`GaussianPsf`]).
3//!
4//! # The model
5//!
6//! A shot is a beam landing at `(x, y)` with a weight (the number of
7//! primaries, or any relative dwell) and an optional spot shape and beam
8//! blur. The deposited energy density at a point is the sum over shots of the
9//! shot's weight times the PSF, `E f(r)` (eV/m²), centred on the shot, where
10//! `E` is [`GaussianPsf::total_ev`], the energy one primary deposits in the
11//! film. The PSF is the sum of Gaussians documented in
12//! [`crate::tally::psf`]:
13//!
14//! ```text
15//! f(r) = 1/(π Σc) Σ_t (c_t / w_t²) exp(-r²/w_t²)
16//! ```
17//!
18//! with `(w_t, c_t) = (α, 1), (β, η)` and, for the triple form, `(γ, ν)`.
19//!
20//! **Beam blur.** A Gaussian beam profile of width `b` in the same
21//! convention, `exp(-r²/b²)/(π b²)`, is convolved with the PSF. The
22//! convolution of two normalised 2D Gaussians is the normalised Gaussian
23//! whose `w²` is the sum (the variances of independent Gaussians add; here
24//! `w² = 2σ²`, so the `w²` add too):
25//!
26//! ```text
27//! ∫ exp(-|r-s|²/w²)/(π w²) · exp(-s²/b²)/(π b²) d²s = exp(-r²/(w²+b²))/(π (w²+b²))
28//! ```
29//!
30//! This is textbook (the convolution theorem for Gaussians; it follows from
31//! completing the square in the exponent), so a blurred point shot needs no
32//! numerical convolution: every term's `w_t²` becomes `w_t² + b²`, and the
33//! weights `c_t` and the normalisation are unchanged. The blur of a shot is
34//! `b² = b_global² + b_shot²`.
35//!
36//! **Shaped spots.** A uniform disc or rectangle is a fixed sub-sampling
37//! into point shots: the bounding box of the spot is cut into
38//! `n × n` equal cells (`n` is [`Exposure::subsamples`], default
39//! [`DEFAULT_SUBSAMPLES`]); each cell centre inside the spot becomes a point
40//! shot with an equal share of the weight (the shares sum to the shot's
41//! weight). The rectangle is the midpoint rule on the exact shape, so its
42//! error is `O(h²)` in the cell pitch `h` relative to the narrowest Gaussian
43//! width `sqrt(w_t² + b²)`; it is accurate when `h` is below that width. A
44//! disc has the same quadrature error and, in addition, the boundary of the
45//! sampled cells approximates the circle to `O(h)` in area; raise
46//! `subsamples` for a spot that is large compared with the PSF's `α`.
47//!
48//! # Determinism
49//!
50//! The dose at one point sums the point sources in list order (the shots in
51//! the order given, each shot's sub-samples in row-major order). A grid
52//! parallelises over grid points only, so each point's sum is the same fixed
53//! sequence of floating-point operations: the result is bit-identical at any
54//! thread count.
55//!
56//! # Accuracy
57//!
58//! The dose is only as good as the PSF it uses. A fit that does not describe
59//! the simulated profile (see the reduced `χ²` of [`PsfFit`]) gives a dose
60//! that does not describe the simulated exposure either; the fit's validation
61//! is tracked separately (issue #151). Superposition also assumes the film is
62//! laterally uniform.
63//!
64//! # Direct Monte Carlo exposure
65//!
66//! [`direct_dose`] evaluates the same shot list by transport instead of by
67//! superposition, which makes no assumption of a laterally uniform film. For
68//! each shot the electron engine ([`Transport::run_beam`]) runs primaries from
69//! the shot position with the shot's spot shape, and the energy deposited in
70//! the film slab `depth_lo <= x < depth_hi` is tallied per lateral cell of the
71//! evaluation [`Grid`] (one voxel per grid point, the point at its centre,
72//! through [`VoxelTallyGrid`]). The shot's weight scales the per-primary
73//! deposit, so the energy density at a point is
74//!
75//! ```text
76//! D(p) = Σ_shots w_s · (E_s(p) / N_s) / (dx · dy)
77//! ```
78//!
79//! with `E_s(p)` the energy deposited in the cell of `p` by the `N_s`
80//! primaries of shot `s`. This is the cell average of the density that
81//! [`Exposure::dose_at`] gives at the cell centre; the two agree when the
82//! dose varies little over a cell (choose `dx`, `dy` below the narrowest PSF
83//! width).
84//!
85//! **Frame.** Exposure coordinates `(x, y)` are offsets from the geometry's
86//! entry point (the centre of the front face of a voxel grid), as for
87//! [`BeamSpread::landing_m`]: exposure `x` is the geometry's `y` and exposure
88//! `y` its `z`. Depth is the geometry's `x`.
89//!
90//! **Spots.** A point shot is a Gaussian beam of the blur width (`b` in
91//! `exp(-r²/b²)`, i.e. `σ = b/√2` per axis); a disc or rectangle is the
92//! uniform profile of the same shape. A shaped spot with a nonzero blur is
93//! rejected: the engine's profiles do not convolve a shape with a Gaussian.
94//!
95//! **Statistical error.** The run is made in `R` independent replicas
96//! ([`DirectConfig::replicas`]), each with its own seeds and
97//! `histories_per_shot / R` primaries per shot. The reported dose at a point
98//! is the mean of the `R` replica doses and its standard error
99//! `sqrt(s² / R)`, with `s²` the sample variance of the replica doses
100//! (Bessel-corrected). The error is statistical only (Monte Carlo noise); the
101//! cross-section tables' own uncertainty is not included.
102//!
103//! **Determinism.** Replica `r` and shot `s` run with seed
104//! `point_seed(point_seed(seed, r), s)` (a SplitMix64 mix, see
105//! [`crate::tally::psf_scan`]), runs are made one after the other, each is
106//! bit-identical at any thread count, and the sums over shots and replicas
107//! are taken in index order, so the result does not depend on the thread
108//! count.
109//!
110//! **Comparison with the convolution.** [`compare_with_convolution`] puts the
111//! two results side by side per point (the convolution averaged over the
112//! same cell, as the tally measures a cell average) and flags the points where they differ
113//! by more than a stated number of combined standard errors,
114//! `sqrt(σ_direct² + σ_conv²)`. The superposition has no sampling error of
115//! its own; `σ_conv` is the stated relative uncertainty of the fitted PSF
116//! times the convolution dose (0 if none is given).
117//!
118//! [`PsfFit`]: crate::tally::PsfFit
119//! [`Transport::run_beam`]: crate::electron::transport::Transport::run_beam
120//! [`BeamSpread::landing_m`]: crate::electron::transport::BeamSpread
121//! [`VoxelTallyGrid`]: crate::tally::VoxelTallyGrid
122
123use std::f64::consts::PI;
124
125use rayon::prelude::*;
126use thiserror::Error;
127
128use crate::electron::transport::{BeamProfile, BeamSpread, Primary, Transport};
129use crate::geometry::Geometry;
130use crate::tally::psf_scan::point_seed;
131use crate::tally::{Binning, ElectronTallyConfig, FullElectronTally, GaussianPsf, VoxelTallyGrid};
132
133/// Default sub-sampling per axis of a shaped spot.
134pub const DEFAULT_SUBSAMPLES: usize = 16;
135
136/// Errors of building or reading an exposure.
137#[derive(Debug, Error)]
138pub enum ExposureError {
139    /// A parameter is out of range.
140    #[error("invalid exposure: {0}")]
141    Invalid(String),
142    /// The transport rejected the run.
143    #[error("direct exposure: {0}")]
144    Transport(String),
145    /// The shot-list CSV could not be read.
146    #[error("shot list line {line}: {message}")]
147    Csv {
148        /// 1-based line number.
149        line: usize,
150        /// What is wrong.
151        message: String,
152    },
153}
154
155/// The shape of a shot's spot.
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub enum SpotShape {
158    /// A point (a Gaussian beam of the blur width only).
159    Point,
160    /// A uniform disc of radius `radius_m`.
161    Disc {
162        /// Radius, m.
163        radius_m: f64,
164    },
165    /// A uniform axis-aligned rectangle centred on the shot.
166    Rect {
167        /// Extent in x, m.
168        width_m: f64,
169        /// Extent in y, m.
170        height_m: f64,
171    },
172}
173
174/// One shot.
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct Shot {
177    /// Position, m.
178    pub x_m: f64,
179    /// Position, m.
180    pub y_m: f64,
181    /// Number of primaries (or relative dwell): the PSF is scaled by it.
182    pub weight: f64,
183    /// Additional Gaussian beam width `b` of this shot, m, in the
184    /// `exp(-r²/b²)` convention; adds in quadrature to the exposure's blur.
185    pub blur_m: f64,
186    /// Spot shape.
187    pub shape: SpotShape,
188}
189
190impl Shot {
191    /// A point shot with no extra blur.
192    pub fn point(x_m: f64, y_m: f64, weight: f64) -> Self {
193        Self {
194            x_m,
195            y_m,
196            weight,
197            blur_m: 0.0,
198            shape: SpotShape::Point,
199        }
200    }
201
202    fn validate(&self) -> Result<(), ExposureError> {
203        let bad = |what: &str| Err(ExposureError::Invalid(format!("shot {what}: {self:?}")));
204        if !(self.x_m.is_finite() && self.y_m.is_finite() && self.weight.is_finite()) {
205            return bad("position and weight must be finite");
206        }
207        if !(self.blur_m.is_finite() && self.blur_m >= 0.0) {
208            return bad("blur must be finite and non-negative");
209        }
210        let positive = |v: f64| v.is_finite() && v > 0.0;
211        let ok = match self.shape {
212            SpotShape::Point => true,
213            SpotShape::Disc { radius_m } => positive(radius_m),
214            SpotShape::Rect { width_m, height_m } => positive(width_m) && positive(height_m),
215        };
216        if ok {
217            Ok(())
218        } else {
219            bad("spot size must be positive and finite")
220        }
221    }
222}
223
224/// One Gaussian point source after expanding shaped spots.
225#[derive(Debug, Clone, Copy)]
226struct Source {
227    x: f64,
228    y: f64,
229    weight: f64,
230    /// Blur variance `b²`, m².
231    b2: f64,
232}
233
234/// A regular grid of points: point `(i, j)` is at
235/// `(x0 + i dx, y0 + j dy)`, stored row-major (`j * nx + i`).
236#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct Grid {
238    /// x of point `(0, 0)`, m.
239    pub x0_m: f64,
240    /// y of point `(0, 0)`, m.
241    pub y0_m: f64,
242    /// Spacing in x, m.
243    pub dx_m: f64,
244    /// Spacing in y, m.
245    pub dy_m: f64,
246    /// Points in x (at least 1).
247    pub nx: usize,
248    /// Points in y (at least 1).
249    pub ny: usize,
250}
251
252impl Grid {
253    /// Checks the spacings are positive and finite and the counts non-zero.
254    pub fn validate(&self) -> Result<(), ExposureError> {
255        let ok = self.x0_m.is_finite()
256            && self.y0_m.is_finite()
257            && self.dx_m.is_finite()
258            && self.dx_m > 0.0
259            && self.dy_m.is_finite()
260            && self.dy_m > 0.0
261            && self.nx >= 1
262            && self.ny >= 1;
263        if ok {
264            Ok(())
265        } else {
266            Err(ExposureError::Invalid(format!("grid: {self:?}")))
267        }
268    }
269
270    /// Number of points.
271    pub fn len(&self) -> usize {
272        self.nx * self.ny
273    }
274
275    /// True if the grid has no points.
276    pub fn is_empty(&self) -> bool {
277        self.len() == 0
278    }
279
280    /// Position of point `(i, j)`, m.
281    pub fn point(&self, i: usize, j: usize) -> [f64; 2] {
282        [
283            self.x0_m + i as f64 * self.dx_m,
284            self.y0_m + j as f64 * self.dy_m,
285        ]
286    }
287}
288
289/// The dose on a [`Grid`], eV/m², row-major.
290#[derive(Debug, Clone, PartialEq)]
291pub struct DoseGrid {
292    /// The grid.
293    pub grid: Grid,
294    /// Deposited energy density per point, eV/m².
295    pub dose_ev_per_m2: Vec<f64>,
296}
297
298impl DoseGrid {
299    /// The dose at point `(i, j)`.
300    pub fn at(&self, i: usize, j: usize) -> f64 {
301        self.dose_ev_per_m2[j * self.grid.nx + i]
302    }
303
304    /// CSV with columns `x_m,y_m,dose_ev_per_m2`, one row per point, rows in
305    /// grid order.
306    pub fn csv(&self) -> String {
307        let mut s = String::from("x_m,y_m,dose_ev_per_m2\n");
308        for j in 0..self.grid.ny {
309            for i in 0..self.grid.nx {
310                let [x, y] = self.grid.point(i, j);
311                s.push_str(&format!("{x},{y},{}\n", self.at(i, j)));
312            }
313        }
314        s
315    }
316}
317
318/// A list of shots on a PSF, ready to evaluate.
319#[derive(Debug, Clone)]
320pub struct Exposure {
321    psf: GaussianPsf,
322    blur_m: f64,
323    subsamples: usize,
324    shots: Vec<Shot>,
325    sources: Vec<Source>,
326    /// `(w_t², c_t / Σc)` of the PSF.
327    terms: Vec<(f64, f64)>,
328}
329
330impl Exposure {
331    /// An exposure of `shots` on `psf` with a global beam blur `blur_m`
332    /// (`b` in `exp(-r²/b²)`, 0 for none) and [`DEFAULT_SUBSAMPLES`].
333    pub fn new(psf: GaussianPsf, shots: Vec<Shot>, blur_m: f64) -> Result<Self, ExposureError> {
334        Self::with_subsamples(psf, shots, blur_m, DEFAULT_SUBSAMPLES)
335    }
336
337    /// As [`Exposure::new`], with `subsamples` cells per axis for shaped
338    /// spots (at least 1).
339    pub fn with_subsamples(
340        psf: GaussianPsf,
341        shots: Vec<Shot>,
342        blur_m: f64,
343        subsamples: usize,
344    ) -> Result<Self, ExposureError> {
345        psf.validate()
346            .map_err(|e| ExposureError::Invalid(e.to_string()))?;
347        if !(blur_m.is_finite() && blur_m >= 0.0) {
348            return Err(ExposureError::Invalid(format!(
349                "blur must be finite and non-negative, got {blur_m}"
350            )));
351        }
352        if subsamples == 0 {
353            return Err(ExposureError::Invalid(
354                "subsamples must be at least 1".into(),
355            ));
356        }
357        for s in &shots {
358            s.validate()?;
359        }
360        let terms = psf_terms(&psf);
361        let sources = expand(&shots, blur_m, subsamples);
362        Ok(Self {
363            psf,
364            blur_m,
365            subsamples,
366            shots,
367            sources,
368            terms,
369        })
370    }
371
372    /// The PSF.
373    pub fn psf(&self) -> &GaussianPsf {
374        &self.psf
375    }
376
377    /// The global beam blur `b`, m.
378    pub fn blur_m(&self) -> f64 {
379        self.blur_m
380    }
381
382    /// Sub-samples per axis of a shaped spot.
383    pub fn subsamples(&self) -> usize {
384        self.subsamples
385    }
386
387    /// The shots, in the order they are summed.
388    pub fn shots(&self) -> &[Shot] {
389        &self.shots
390    }
391
392    /// The deposited energy density at `(x, y)`, eV/m².
393    pub fn dose_at(&self, x_m: f64, y_m: f64) -> f64 {
394        let mut sum = 0.0;
395        for s in &self.sources {
396            let d2 = (x_m - s.x) * (x_m - s.x) + (y_m - s.y) * (y_m - s.y);
397            let mut f = 0.0;
398            for &(w2, c) in &self.terms {
399                let v = w2 + s.b2;
400                f += c / v * (-d2 / v).exp();
401            }
402            sum += s.weight * f;
403        }
404        self.psf.total_ev * sum / PI
405    }
406
407    /// The dose at each of `points`, in order. Parallel over points; the
408    /// result does not depend on the thread count.
409    pub fn dose_at_points(&self, points: &[[f64; 2]]) -> Vec<f64> {
410        points
411            .par_iter()
412            .map(|p| self.dose_at(p[0], p[1]))
413            .collect()
414    }
415
416    /// The dose on a grid. Parallel over grid points; the result does not
417    /// depend on the thread count.
418    pub fn dose_on_grid(&self, grid: &Grid) -> Result<DoseGrid, ExposureError> {
419        grid.validate()?;
420        let nx = grid.nx;
421        let dose: Vec<f64> = (0..grid.len())
422            .into_par_iter()
423            .map(|k| {
424                let [x, y] = grid.point(k % nx, k / nx);
425                self.dose_at(x, y)
426            })
427            .collect();
428        Ok(DoseGrid {
429            grid: *grid,
430            dose_ev_per_m2: dose,
431        })
432    }
433}
434
435/// `(w_t², c_t / Σc)` of a PSF, the `α` term first.
436fn psf_terms(psf: &GaussianPsf) -> Vec<(f64, f64)> {
437    let mut t = vec![(psf.alpha_m, 1.0), (psf.beta_m, psf.eta)];
438    if let (Some(g), Some(n)) = (psf.gamma_m, psf.nu) {
439        t.push((g, n));
440    }
441    let sum: f64 = t.iter().map(|x| x.1).sum();
442    t.into_iter().map(|(w, c)| (w * w, c / sum)).collect()
443}
444
445/// Shots to point sources, in list order (see the module docs).
446fn expand(shots: &[Shot], global_blur_m: f64, n: usize) -> Vec<Source> {
447    let mut out = Vec::with_capacity(shots.len());
448    for s in shots {
449        let b2 = global_blur_m * global_blur_m + s.blur_m * s.blur_m;
450        let (hx, hy) = match s.shape {
451            SpotShape::Point => {
452                out.push(Source {
453                    x: s.x_m,
454                    y: s.y_m,
455                    weight: s.weight,
456                    b2,
457                });
458                continue;
459            }
460            SpotShape::Disc { radius_m } => (radius_m, radius_m),
461            SpotShape::Rect { width_m, height_m } => (width_m / 2.0, height_m / 2.0),
462        };
463        let mut cells = Vec::with_capacity(n * n);
464        for j in 0..n {
465            for i in 0..n {
466                let ox = -hx + (i as f64 + 0.5) * (2.0 * hx / n as f64);
467                let oy = -hy + (j as f64 + 0.5) * (2.0 * hy / n as f64);
468                let inside = match s.shape {
469                    SpotShape::Disc { radius_m } => ox * ox + oy * oy <= radius_m * radius_m,
470                    _ => true,
471                };
472                if inside {
473                    cells.push((ox, oy));
474                }
475            }
476        }
477        let share = s.weight / cells.len() as f64;
478        out.extend(cells.into_iter().map(|(ox, oy)| Source {
479            x: s.x_m + ox,
480            y: s.y_m + oy,
481            weight: share,
482            b2,
483        }));
484    }
485    out
486}
487
488/// The dose far inside a large uniform exposure, split by PSF term.
489#[derive(Debug, Clone, PartialEq)]
490pub struct AreaDose {
491    /// The total, eV/m²: `weight_per_m2 · E`. Independent of every width, as
492    /// the PSF integrates to `E` (beam blur does not change it either).
493    pub total_ev_per_m2: f64,
494    /// The forward-scattering (`α`) term's share, eV/m².
495    pub forward_ev_per_m2: f64,
496    /// The long-range terms' share (`β`, and `γ` for the triple form),
497    /// eV/m²: the backscatter contribution.
498    pub long_range_ev_per_m2: f64,
499}
500
501/// The area dose of a uniform exposure of `weight_per_m2` primaries per m²
502/// over an area much larger than the PSF's longest width, with the
503/// forward-only part reported next to the total.
504///
505/// Each normalised Gaussian term integrates to 1 over the plane, so a term
506/// with share `c_t/Σc` contributes `weight_per_m2 · E · c_t/Σc` and the
507/// total is `weight_per_m2 · E`.
508pub fn uniform_area_dose(psf: &GaussianPsf, weight_per_m2: f64) -> AreaDose {
509    let t = psf_terms(psf);
510    let total = weight_per_m2 * psf.total_ev;
511    let forward = total * t[0].1;
512    AreaDose {
513        total_ev_per_m2: total,
514        forward_ev_per_m2: forward,
515        long_range_ev_per_m2: total - forward,
516    }
517}
518
519/// Reads a shot list.
520///
521/// A CSV with a header row. Required columns `x_m`, `y_m`, `weight`;
522/// optional `blur_m` (default 0), `shape` (`point`, the default, `disc` or
523/// `rect`), `size_m` (disc radius, or rectangle width) and `size2_m`
524/// (rectangle height, default `size_m`). Blank lines and lines starting with
525/// `#` are skipped. Lengths are in metres.
526pub fn parse_shots_csv(text: &str) -> Result<Vec<Shot>, ExposureError> {
527    let err = |line: usize, message: String| ExposureError::Csv { line, message };
528    let mut lines = text
529        .lines()
530        .enumerate()
531        .map(|(i, l)| (i + 1, l.trim()))
532        .filter(|(_, l)| !l.is_empty() && !l.starts_with('#'));
533    let (hline, header) = lines.next().ok_or_else(|| err(1, "no header row".into()))?;
534    let names: Vec<&str> = header.split(',').map(str::trim).collect();
535    let col = |n: &str| names.iter().position(|c| *c == n);
536    let need = |n: &str| col(n).ok_or_else(|| err(hline, format!("missing column `{n}`")));
537    let (cx, cy, cw) = (need("x_m")?, need("y_m")?, need("weight")?);
538    let (cb, cs, c1, c2) = (col("blur_m"), col("shape"), col("size_m"), col("size2_m"));
539    let mut shots = Vec::new();
540    for (n, line) in lines {
541        let f: Vec<&str> = line.split(',').map(str::trim).collect();
542        if f.len() != names.len() {
543            return Err(err(
544                n,
545                format!("expected {} fields, found {}", names.len(), f.len()),
546            ));
547        }
548        let num = |i: usize| {
549            f[i].parse::<f64>()
550                .map_err(|_| err(n, format!("`{}` is not a number", f[i])))
551        };
552        let opt = |c: Option<usize>| -> Result<Option<f64>, ExposureError> {
553            match c {
554                Some(i) if !f[i].is_empty() => num(i).map(Some),
555                _ => Ok(None),
556            }
557        };
558        let size = opt(c1)?;
559        let size2 = opt(c2)?;
560        let need_size = |what: &str| size.ok_or_else(|| err(n, format!("{what} needs size_m")));
561        let shape = match cs.map(|i| f[i]).unwrap_or("point") {
562            "" | "point" => SpotShape::Point,
563            "disc" => SpotShape::Disc {
564                radius_m: need_size("disc")?,
565            },
566            "rect" => {
567                let w = need_size("rect")?;
568                SpotShape::Rect {
569                    width_m: w,
570                    height_m: size2.unwrap_or(w),
571                }
572            }
573            other => return Err(err(n, format!("unknown shape `{other}`"))),
574        };
575        let shot = Shot {
576            x_m: num(cx)?,
577            y_m: num(cy)?,
578            weight: num(cw)?,
579            blur_m: opt(cb)?.unwrap_or(0.0),
580            shape,
581        };
582        shot.validate().map_err(|e| err(n, e.to_string()))?;
583        shots.push(shot);
584    }
585    Ok(shots)
586}
587
588/// Settings of [`direct_dose`].
589#[derive(Debug, Clone, Copy, PartialEq)]
590pub struct DirectConfig {
591    /// Primaries per shot, summed over the replicas (at least `replicas`).
592    pub histories_per_shot: u64,
593    /// Independent replicas for the statistical error (at least 2).
594    pub replicas: usize,
595    /// Histories per tally chunk (see `Transport::run_beam`).
596    pub chunk_size: u64,
597    /// Base seed.
598    pub seed: u64,
599    /// Lower depth of the film slab, m.
600    pub depth_lo_m: f64,
601    /// Upper depth of the film slab (excluded), m.
602    pub depth_hi_m: f64,
603}
604
605/// The energy density of a shot list by transport, with its statistical
606/// error, on a [`Grid`].
607#[derive(Debug, Clone, PartialEq)]
608pub struct DirectDose {
609    /// The evaluation grid.
610    pub grid: Grid,
611    /// Mean over replicas of the deposited energy density, eV/m², row-major.
612    pub dose_ev_per_m2: Vec<f64>,
613    /// Standard error of each mean, eV/m².
614    pub std_err_ev_per_m2: Vec<f64>,
615    /// Replicas.
616    pub replicas: usize,
617    /// Primaries per shot in each replica.
618    pub histories_per_shot_per_replica: u64,
619}
620
621impl DirectDose {
622    /// CSV with columns `x_m,y_m,dose_ev_per_m2,std_err_ev_per_m2`, rows in
623    /// grid order.
624    pub fn csv(&self) -> String {
625        let mut s = String::from("x_m,y_m,dose_ev_per_m2,std_err_ev_per_m2\n");
626        for j in 0..self.grid.ny {
627            for i in 0..self.grid.nx {
628                let [x, y] = self.grid.point(i, j);
629                let k = j * self.grid.nx + i;
630                s.push_str(&format!(
631                    "{x},{y},{},{}\n",
632                    self.dose_ev_per_m2[k], self.std_err_ev_per_m2[k]
633                ));
634            }
635        }
636        s
637    }
638}
639
640/// The beam of a shot: its landing point and lateral profile.
641fn shot_beam(shot: &Shot, global_blur_m: f64) -> Result<BeamSpread, ExposureError> {
642    let b2 = global_blur_m * global_blur_m + shot.blur_m * shot.blur_m;
643    let profile = match shot.shape {
644        SpotShape::Point if b2 > 0.0 => BeamProfile::Gaussian {
645            sigma_m: (b2 / 2.0).sqrt(),
646        },
647        SpotShape::Point => BeamProfile::Point,
648        _ if b2 > 0.0 => {
649            return Err(ExposureError::Invalid(format!(
650                "a shaped spot with a nonzero blur is not supported by the direct mode: {shot:?}"
651            )))
652        }
653        SpotShape::Disc { radius_m } => BeamProfile::Disc { radius_m },
654        SpotShape::Rect { width_m, height_m } => BeamProfile::Rectangle {
655            width_y_m: width_m,
656            width_z_m: height_m,
657        },
658    };
659    Ok(BeamSpread {
660        landing_m: [shot.x_m, shot.y_m],
661        profile,
662        ..BeamSpread::default()
663    })
664}
665
666/// The energy density of `shots` on `grid` by electron transport; see the
667/// module docs ("Direct Monte Carlo exposure") for the model, frame and
668/// error. `global_blur_m` is the exposure's beam blur `b`.
669pub fn direct_dose<G: Geometry>(
670    transport: &Transport<G>,
671    primary: &Primary,
672    shots: &[Shot],
673    global_blur_m: f64,
674    grid: &Grid,
675    cfg: &DirectConfig,
676) -> Result<DirectDose, ExposureError> {
677    grid.validate()?;
678    if !(global_blur_m.is_finite() && global_blur_m >= 0.0) {
679        return Err(ExposureError::Invalid(format!(
680            "blur must be finite and non-negative, got {global_blur_m}"
681        )));
682    }
683    if cfg.replicas < 2 {
684        return Err(ExposureError::Invalid("replicas must be at least 2".into()));
685    }
686    if cfg.chunk_size == 0 {
687        return Err(ExposureError::Invalid("chunk size must be positive".into()));
688    }
689    let n_r = cfg.histories_per_shot / cfg.replicas as u64;
690    if n_r == 0 {
691        return Err(ExposureError::Invalid(
692            "histories per shot must be at least the number of replicas".into(),
693        ));
694    }
695    for s in shots {
696        s.validate()?;
697    }
698    let beams: Vec<BeamSpread> = shots
699        .iter()
700        .map(|s| shot_beam(s, global_blur_m))
701        .collect::<Result<_, _>>()?;
702
703    let entry = transport.geometry().entry_point();
704    let tally_grid = VoxelTallyGrid::new(
705        [
706            cfg.depth_lo_m,
707            entry[1] + grid.x0_m - grid.dx_m / 2.0,
708            entry[2] + grid.y0_m - grid.dy_m / 2.0,
709        ],
710        [cfg.depth_hi_m - cfg.depth_lo_m, grid.dx_m, grid.dy_m],
711        [1, grid.nx, grid.ny],
712    )
713    .map_err(|e| ExposureError::Invalid(format!("tally grid: {e}")))?;
714    let mut tally_config = ElectronTallyConfig::new(
715        Binning::new(0.0, primary.energy_ev, 1)
716            .map_err(|e| ExposureError::Invalid(e.to_string()))?,
717        Binning::new(0.0, std::f64::consts::FRAC_PI_2, 1).expect("one polar bin"),
718    );
719    tally_config.psf = None;
720    let proto = FullElectronTally::new(transport, tally_config)
721        .map_err(|e| ExposureError::Transport(e.to_string()))?
722        .with_voxel_grid(tally_grid);
723
724    let n = grid.len();
725    let area = grid.dx_m * grid.dy_m;
726    let mut replica_dose: Vec<Vec<f64>> = Vec::with_capacity(cfg.replicas);
727    for r in 0..cfg.replicas {
728        let rep_seed = point_seed(cfg.seed, r as u64);
729        let mut acc = vec![0.0; n];
730        for (s, (shot, beam)) in shots.iter().zip(&beams).enumerate() {
731            if shot.weight == 0.0 {
732                continue;
733            }
734            let run = transport
735                .run_beam(
736                    point_seed(rep_seed, s as u64),
737                    n_r,
738                    cfg.chunk_size,
739                    primary,
740                    beam,
741                    || proto.clone(),
742                )
743                .map_err(|e| ExposureError::Transport(e.to_string()))?;
744            let report = run
745                .tally
746                .voxel_report()
747                .expect("the voxel grid was configured");
748            for v in &report.voxels {
749                acc[v.index[2] * grid.nx + v.index[1]] +=
750                    shot.weight * v.cell.deposited_ev() / n_r as f64;
751            }
752        }
753        for a in &mut acc {
754            *a /= area;
755        }
756        replica_dose.push(acc);
757    }
758    let rn = cfg.replicas as f64;
759    let mut dose = Vec::with_capacity(n);
760    let mut err = Vec::with_capacity(n);
761    for k in 0..n {
762        let mean = replica_dose.iter().map(|d| d[k]).sum::<f64>() / rn;
763        let var = replica_dose
764            .iter()
765            .map(|d| (d[k] - mean) * (d[k] - mean))
766            .sum::<f64>()
767            / (rn - 1.0);
768        dose.push(mean);
769        err.push((var / rn).sqrt());
770    }
771    Ok(DirectDose {
772        grid: *grid,
773        dose_ev_per_m2: dose,
774        std_err_ev_per_m2: err,
775        replicas: cfg.replicas,
776        histories_per_shot_per_replica: n_r,
777    })
778}
779
780/// Sub-cells per axis of the cell average of the convolution dose.
781const CELL_SUBSAMPLES: usize = 8;
782
783/// The superposition dose averaged over the `dx × dy` cell centred on
784/// `(x, y)` (midpoint rule on `CELL_SUBSAMPLES²` points), the quantity the
785/// transport tally measures.
786fn cell_average(e: &Exposure, x: f64, y: f64, dx: f64, dy: f64) -> f64 {
787    let n = CELL_SUBSAMPLES;
788    let mut sum = 0.0;
789    for j in 0..n {
790        for i in 0..n {
791            let ox = ((i as f64 + 0.5) / n as f64 - 0.5) * dx;
792            let oy = ((j as f64 + 0.5) / n as f64 - 0.5) * dy;
793            sum += e.dose_at(x + ox, y + oy);
794        }
795    }
796    sum / (n * n) as f64
797}
798
799/// One evaluation point of [`compare_with_convolution`].
800#[derive(Debug, Clone, Copy, PartialEq)]
801pub struct PointComparison {
802    /// Point, m.
803    pub x_m: f64,
804    /// Point, m.
805    pub y_m: f64,
806    /// Dose from the superposition, eV/m².
807    pub convolution_ev_per_m2: f64,
808    /// Dose from the transport run, eV/m².
809    pub direct_ev_per_m2: f64,
810    /// Combined standard error, eV/m².
811    pub combined_std_err_ev_per_m2: f64,
812    /// `(direct - convolution) / combined`; infinite with sign when the
813    /// error is 0 and the two differ, 0 when they are equal.
814    pub deviation_sigmas: f64,
815    /// Whether `|deviation_sigmas|` exceeds the tolerance at a point above
816    /// the resolution floor.
817    pub flagged: bool,
818}
819
820/// The direct and convolution doses side by side at every point of
821/// `direct.grid`; a point is flagged when the two differ by more than
822/// `tolerance_sigmas` combined standard errors (see the module docs).
823/// `conv_rel_err` is the relative uncertainty assigned to the convolution.
824///
825/// **Resolution floor.** A transport run cannot resolve a dose far below the
826/// grid's peak: with few or no deposits in a cell the replica variance is
827/// zero or itself noise. A point where both doses are below
828/// `floor_fraction` times the largest dose of either method on the grid is
829/// reported but never flagged (0 judges every point). Choose it with the
830/// histories per shot in mind: it should be well above `1 / (histories per
831/// replica)` of the peak.
832pub fn compare_with_convolution(
833    direct: &DirectDose,
834    convolution: &Exposure,
835    conv_rel_err: f64,
836    tolerance_sigmas: f64,
837    floor_fraction: f64,
838) -> Result<Vec<PointComparison>, ExposureError> {
839    if !(floor_fraction.is_finite() && (0.0..1.0).contains(&floor_fraction)) {
840        return Err(ExposureError::Invalid(
841            "floor fraction must be in [0, 1)".into(),
842        ));
843    }
844    if !(conv_rel_err.is_finite() && conv_rel_err >= 0.0) {
845        return Err(ExposureError::Invalid(
846            "convolution relative error must be finite and non-negative".into(),
847        ));
848    }
849    if !(tolerance_sigmas.is_finite() && tolerance_sigmas > 0.0) {
850        return Err(ExposureError::Invalid(
851            "tolerance must be finite and positive".into(),
852        ));
853    }
854    let g = &direct.grid;
855    let conv_doses: Vec<f64> = (0..g.len())
856        .map(|k| {
857            let [x, y] = g.point(k % g.nx, k / g.nx);
858            cell_average(convolution, x, y, g.dx_m, g.dy_m)
859        })
860        .collect();
861    let peak = conv_doses
862        .iter()
863        .chain(&direct.dose_ev_per_m2)
864        .fold(0.0_f64, |a, &b| a.max(b));
865    let floor = floor_fraction * peak;
866    let mut out = Vec::with_capacity(g.len());
867    for j in 0..g.ny {
868        for i in 0..g.nx {
869            let k = j * g.nx + i;
870            let [x, y] = g.point(i, j);
871            let conv = conv_doses[k];
872            let d = direct.dose_ev_per_m2[k];
873            let se = direct.std_err_ev_per_m2[k].hypot(conv_rel_err * conv);
874            let diff = d - conv;
875            let dev = if diff == 0.0 {
876                0.0
877            } else if se > 0.0 {
878                diff / se
879            } else {
880                f64::INFINITY.copysign(diff)
881            };
882            out.push(PointComparison {
883                x_m: x,
884                y_m: y,
885                convolution_ev_per_m2: conv,
886                direct_ev_per_m2: d,
887                combined_std_err_ev_per_m2: se,
888                deviation_sigmas: dev,
889                flagged: dev.abs() > tolerance_sigmas && conv.max(d) >= floor,
890            });
891        }
892    }
893    Ok(out)
894}
895
896/// CSV of a comparison: `x_m,y_m,convolution_ev_per_m2,direct_ev_per_m2,
897/// combined_std_err_ev_per_m2,deviation_sigmas,flagged`.
898pub fn comparison_csv(points: &[PointComparison]) -> String {
899    let mut s = String::from(
900        "x_m,y_m,convolution_ev_per_m2,direct_ev_per_m2,combined_std_err_ev_per_m2,deviation_sigmas,flagged\n",
901    );
902    for p in points {
903        s.push_str(&format!(
904            "{},{},{},{},{},{},{}\n",
905            p.x_m,
906            p.y_m,
907            p.convolution_ev_per_m2,
908            p.direct_ev_per_m2,
909            p.combined_std_err_ev_per_m2,
910            p.deviation_sigmas,
911            u8::from(p.flagged)
912        ));
913    }
914    s
915}