1use 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
133pub const DEFAULT_SUBSAMPLES: usize = 16;
135
136#[derive(Debug, Error)]
138pub enum ExposureError {
139 #[error("invalid exposure: {0}")]
141 Invalid(String),
142 #[error("direct exposure: {0}")]
144 Transport(String),
145 #[error("shot list line {line}: {message}")]
147 Csv {
148 line: usize,
150 message: String,
152 },
153}
154
155#[derive(Debug, Clone, Copy, PartialEq)]
157pub enum SpotShape {
158 Point,
160 Disc {
162 radius_m: f64,
164 },
165 Rect {
167 width_m: f64,
169 height_m: f64,
171 },
172}
173
174#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct Shot {
177 pub x_m: f64,
179 pub y_m: f64,
181 pub weight: f64,
183 pub blur_m: f64,
186 pub shape: SpotShape,
188}
189
190impl Shot {
191 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#[derive(Debug, Clone, Copy)]
226struct Source {
227 x: f64,
228 y: f64,
229 weight: f64,
230 b2: f64,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct Grid {
238 pub x0_m: f64,
240 pub y0_m: f64,
242 pub dx_m: f64,
244 pub dy_m: f64,
246 pub nx: usize,
248 pub ny: usize,
250}
251
252impl Grid {
253 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 pub fn len(&self) -> usize {
272 self.nx * self.ny
273 }
274
275 pub fn is_empty(&self) -> bool {
277 self.len() == 0
278 }
279
280 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#[derive(Debug, Clone, PartialEq)]
291pub struct DoseGrid {
292 pub grid: Grid,
294 pub dose_ev_per_m2: Vec<f64>,
296}
297
298impl DoseGrid {
299 pub fn at(&self, i: usize, j: usize) -> f64 {
301 self.dose_ev_per_m2[j * self.grid.nx + i]
302 }
303
304 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#[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 terms: Vec<(f64, f64)>,
328}
329
330impl Exposure {
331 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 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 pub fn psf(&self) -> &GaussianPsf {
374 &self.psf
375 }
376
377 pub fn blur_m(&self) -> f64 {
379 self.blur_m
380 }
381
382 pub fn subsamples(&self) -> usize {
384 self.subsamples
385 }
386
387 pub fn shots(&self) -> &[Shot] {
389 &self.shots
390 }
391
392 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 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 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
435fn 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
445fn 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#[derive(Debug, Clone, PartialEq)]
490pub struct AreaDose {
491 pub total_ev_per_m2: f64,
494 pub forward_ev_per_m2: f64,
496 pub long_range_ev_per_m2: f64,
499}
500
501pub 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
519pub 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#[derive(Debug, Clone, Copy, PartialEq)]
590pub struct DirectConfig {
591 pub histories_per_shot: u64,
593 pub replicas: usize,
595 pub chunk_size: u64,
597 pub seed: u64,
599 pub depth_lo_m: f64,
601 pub depth_hi_m: f64,
603}
604
605#[derive(Debug, Clone, PartialEq)]
608pub struct DirectDose {
609 pub grid: Grid,
611 pub dose_ev_per_m2: Vec<f64>,
613 pub std_err_ev_per_m2: Vec<f64>,
615 pub replicas: usize,
617 pub histories_per_shot_per_replica: u64,
619}
620
621impl DirectDose {
622 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
640fn 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
666pub 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
780const CELL_SUBSAMPLES: usize = 8;
782
783fn 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#[derive(Debug, Clone, Copy, PartialEq)]
801pub struct PointComparison {
802 pub x_m: f64,
804 pub y_m: f64,
806 pub convolution_ev_per_m2: f64,
808 pub direct_ev_per_m2: f64,
810 pub combined_std_err_ev_per_m2: f64,
812 pub deviation_sigmas: f64,
815 pub flagged: bool,
818}
819
820pub 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
896pub 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}