1use crate::electron::transport::{disc_offset, finite_positive, gaussian_offset};
84use crate::rng::{stream, uniform};
85use serde::{Deserialize, Serialize};
86
87const MAX_CONVOLUTION_WORK: f64 = 2.0e9;
90
91#[derive(Debug, Clone, PartialEq, thiserror::Error)]
93pub enum ExposureError {
94 #[error("exposure: {0}")]
96 Invalid(String),
97}
98
99fn invalid<T>(msg: impl Into<String>) -> Result<T, ExposureError> {
100 Err(ExposureError::Invalid(msg.into()))
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
105#[serde(tag = "kind", rename_all = "kebab-case")]
106pub enum BeamProfile {
107 Point,
109 Gaussian {
111 sigma_m: f64,
113 },
114 UniformDisc {
116 radius_m: f64,
118 },
119}
120
121impl BeamProfile {
122 pub fn offset(&self, seed: u64, index: u64) -> [f64; 2] {
125 match *self {
126 BeamProfile::Point => [0.0, 0.0],
127 BeamProfile::Gaussian { sigma_m } => {
128 let mut rng = stream(seed, index);
129 let (u1, u2) = (uniform(&mut rng), uniform(&mut rng));
130 gaussian_offset(sigma_m, u1, u2)
131 }
132 BeamProfile::UniformDisc { radius_m } => {
133 let mut rng = stream(seed, index);
134 let (u1, u2) = (uniform(&mut rng), uniform(&mut rng));
135 disc_offset(radius_m, u1, u2)
136 }
137 }
138 }
139
140 fn validate(&self) -> Result<(), ExposureError> {
141 match *self {
142 BeamProfile::Point => Ok(()),
143 BeamProfile::Gaussian { sigma_m } if finite_positive(sigma_m) => Ok(()),
144 BeamProfile::UniformDisc { radius_m } if finite_positive(radius_m) => Ok(()),
145 _ => invalid("beam sigma or radius must be positive and finite"),
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
152#[serde(tag = "kind", rename_all = "kebab-case")]
153pub enum VolumeShape {
154 Cylinder {
156 radius_m: f64,
158 },
159 Box {
161 half_y_m: f64,
163 half_z_m: f64,
165 },
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct ExposureVolume {
174 pub name: String,
176 pub shape: VolumeShape,
178 pub depth_lo_m: f64,
180 pub depth_hi_m: f64,
182 pub offset_y_m: f64,
184 pub offset_z_m: f64,
186}
187
188impl ExposureVolume {
189 fn validate(&self) -> Result<(), ExposureError> {
190 let ok = |v: f64| v.is_finite();
191 let shape_ok = match self.shape {
192 VolumeShape::Cylinder { radius_m } => ok(radius_m) && radius_m > 0.0,
193 VolumeShape::Box { half_y_m, half_z_m } => {
194 ok(half_y_m) && half_y_m > 0.0 && ok(half_z_m) && half_z_m > 0.0
195 }
196 };
197 if !shape_ok {
198 return invalid(format!("volume '{}': size must be positive", self.name));
199 }
200 if !(ok(self.depth_lo_m)
201 && ok(self.depth_hi_m)
202 && self.depth_hi_m > self.depth_lo_m
203 && ok(self.offset_y_m)
204 && ok(self.offset_z_m))
205 {
206 return invalid(format!(
207 "volume '{}': need finite offsets and depth_hi > depth_lo",
208 self.name
209 ));
210 }
211 Ok(())
212 }
213
214 fn contains(&self, x: f64, dy: f64, dz: f64) -> bool {
217 if !(x >= self.depth_lo_m && x < self.depth_hi_m) {
218 return false;
219 }
220 let (y, z) = (dy - self.offset_y_m, dz - self.offset_z_m);
221 match self.shape {
222 VolumeShape::Cylinder { radius_m } => y.hypot(z) < radius_m,
223 VolumeShape::Box { half_y_m, half_z_m } => y.abs() < half_y_m && z.abs() < half_z_m,
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct ExposureConfig {
231 pub volumes: Vec<ExposureVolume>,
233 pub beam: BeamProfile,
235 pub seed: u64,
237 pub bins: usize,
239 pub x_max_ev: f64,
241 pub lambda: f64,
243 pub quantiles: Vec<f64>,
245 pub below_ev: Vec<f64>,
247 pub above_ev: Vec<f64>,
249 pub tail_tolerance: f64,
251}
252
253impl ExposureConfig {
254 pub fn validate(&self) -> Result<(), ExposureError> {
256 if self.volumes.is_empty() {
257 return invalid("at least one volume is required");
258 }
259 for v in &self.volumes {
260 v.validate()?;
261 }
262 self.beam.validate()?;
263 if self.bins == 0 || !(self.x_max_ev.is_finite() && self.x_max_ev > 0.0) {
264 return invalid("bins must be at least 1 and x_max_ev positive");
265 }
266 if !(self.lambda.is_finite() && self.lambda > 0.0) {
267 return invalid("lambda must be positive");
268 }
269 if self.quantiles.iter().any(|p| !(*p > 0.0 && *p < 1.0)) {
270 return invalid("quantiles must lie in (0, 1)");
271 }
272 if self
273 .below_ev
274 .iter()
275 .chain(&self.above_ev)
276 .any(|t| !t.is_finite())
277 {
278 return invalid("thresholds must be finite");
279 }
280 if !(self.tail_tolerance > 0.0 && self.tail_tolerance < 1.0) {
281 return invalid("tail_tolerance must lie in (0, 1)");
282 }
283 Ok(())
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct ExposureHistogram {
291 pub histories: u64,
293 pub zero_count: u64,
295 pub bin_width_ev: f64,
297 pub counts: Vec<u64>,
299 pub overflow_count: u64,
301 pub sum_x: f64,
303 pub sum_x2: f64,
305 pub sum_x3: f64,
307 pub sum_x4: f64,
309}
310
311impl ExposureHistogram {
312 fn new(bins: usize, x_max_ev: f64) -> Self {
313 Self {
314 histories: 0,
315 zero_count: 0,
316 bin_width_ev: x_max_ev / bins as f64,
317 counts: vec![0; bins],
318 overflow_count: 0,
319 sum_x: 0.0,
320 sum_x2: 0.0,
321 sum_x3: 0.0,
322 sum_x4: 0.0,
323 }
324 }
325
326 fn push(&mut self, x: f64) {
327 self.histories += 1;
328 if x <= 0.0 {
329 self.zero_count += 1;
330 return;
331 }
332 let bins = self.counts.len();
333 let j = (x / self.bin_width_ev).ceil() as usize; if j > bins {
335 self.overflow_count += 1;
336 }
337 self.counts[j.clamp(1, bins) - 1] += 1;
338 let x2 = x * x;
339 self.sum_x += x;
340 self.sum_x2 += x2;
341 self.sum_x3 += x2 * x;
342 self.sum_x4 += x2 * x2;
343 }
344
345 fn merge(&mut self, o: &Self) {
346 self.histories += o.histories;
347 self.zero_count += o.zero_count;
348 self.overflow_count += o.overflow_count;
349 for (a, b) in self.counts.iter_mut().zip(&o.counts) {
350 *a += b;
351 }
352 self.sum_x += o.sum_x;
353 self.sum_x2 += o.sum_x2;
354 self.sum_x3 += o.sum_x3;
355 self.sum_x4 += o.sum_x4;
356 }
357
358 pub fn mean_ev(&self) -> f64 {
360 self.sum_x / self.histories.max(1) as f64
361 }
362
363 pub fn second_moment_ev2(&self) -> f64 {
365 self.sum_x2 / self.histories.max(1) as f64
366 }
367
368 pub fn mean_std_err_ev(&self) -> f64 {
370 let n = self.histories as f64;
371 if self.histories < 2 {
372 return 0.0;
373 }
374 let var = (self.second_moment_ev2() - self.mean_ev().powi(2)).max(0.0);
375 (var * n / (n - 1.0) / n).sqrt()
376 }
377
378 pub fn second_moment_std_err_ev2(&self) -> f64 {
380 let n = self.histories as f64;
381 if self.histories < 2 {
382 return 0.0;
383 }
384 let m2 = self.second_moment_ev2();
385 let var = (self.sum_x4 / n - m2 * m2).max(0.0);
386 (var * n / (n - 1.0) / n).sqrt()
387 }
388
389 pub fn zero_fraction(&self) -> f64 {
391 self.zero_count as f64 / self.histories.max(1) as f64
392 }
393
394 pub fn compound_poisson(
398 &self,
399 lambda: f64,
400 request: &CompoundRequest,
401 ) -> Result<CompoundPoisson, ExposureError> {
402 if !(lambda.is_finite() && lambda > 0.0) {
403 return invalid("lambda must be positive");
404 }
405 if self.histories == 0 {
406 return invalid("no histories");
407 }
408 let mean = lambda * self.mean_ev();
409 let variance = lambda * self.second_moment_ev2();
410 let mut out = CompoundPoisson {
411 lambda,
412 mean_ev: mean,
413 mean_std_err_ev: lambda * self.mean_std_err_ev(),
414 variance_ev2: variance,
415 variance_std_err_ev2: lambda * self.second_moment_std_err_ev2(),
416 p_zero: 0.0,
417 k_max: 0,
418 truncated_mass: 0.0,
419 bin_width_ev: self.bin_width_ev,
420 overflow_fraction: self.overflow_count as f64 / self.histories as f64,
421 distribution: None,
422 note: None,
423 };
424 let p0 = self.zero_fraction();
425 let nz_total: u64 = self.counts.iter().sum();
426 out.p_zero = (-lambda * (1.0 - p0)).exp();
428 if nz_total == 0 {
429 out.k_max = 0;
430 out.distribution = Some(distribution_atom_at_zero(request));
431 return Ok(out);
432 }
433 let mu = lambda * (1.0 - p0); let (poisson, k_max, trunc) = poisson_pmf(mu, request.tail_tolerance);
435 out.k_max = k_max;
436 out.truncated_mass = trunc;
437 let bins = self.counts.len();
438 let work = (k_max as f64).powi(2) * (bins as f64).powi(2) / 2.0;
439 if work > MAX_CONVOLUTION_WORK {
440 out.note = Some(format!(
441 "distribution not computed: lambda too large for the convolution \
442 (k_max {k_max}, {bins} bins); mean and variance are exact"
443 ));
444 return Ok(out);
445 }
446 let f: Vec<f64> = self
447 .counts
448 .iter()
449 .map(|&c| c as f64 / nz_total as f64)
450 .collect();
451 let size = 2 * k_max * bins + 1;
454 let mut lo = vec![0.0; size];
455 let mut mid = vec![0.0; size];
456 let mut hi = vec![0.0; size];
457 lo[0] += poisson[0];
458 mid[0] += poisson[0];
459 hi[0] += poisson[0];
460 let mut g = vec![1.0]; for k in 1..=k_max {
462 let mut next = vec![0.0; g.len() + bins];
464 for (m, &gm) in g.iter().enumerate() {
465 if gm == 0.0 {
466 continue;
467 }
468 for (j, &fj) in f.iter().enumerate() {
469 next[m + j + 1] += gm * fj;
470 }
471 }
472 g = next;
473 let pk = poisson[k];
475 for (m, &gm) in g.iter().enumerate() {
476 if gm == 0.0 {
477 continue;
478 }
479 let w = pk * gm;
480 lo[2 * (m - k)] += w;
482 mid[2 * m - k] += w;
483 hi[2 * m] += w;
484 }
485 }
486 out.distribution = Some(summarise(self.bin_width_ev, &lo, &mid, &hi, request));
487 Ok(out)
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493pub struct CompoundRequest {
494 pub quantiles: Vec<f64>,
496 pub below_ev: Vec<f64>,
498 pub above_ev: Vec<f64>,
500 pub tail_tolerance: f64,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
510pub struct Bracket {
511 pub low: f64,
513 pub central: f64,
515 pub high: f64,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
521pub struct QuantileResult {
522 pub p: f64,
524 pub energy_ev: Bracket,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
530pub struct ThresholdResult {
531 pub threshold_ev: f64,
533 pub probability: Bracket,
535}
536
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
539pub struct CompoundDistribution {
540 pub quantiles: Vec<QuantileResult>,
542 pub below: Vec<ThresholdResult>,
544 pub above: Vec<ThresholdResult>,
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
550pub struct CompoundPoisson {
551 pub lambda: f64,
553 pub mean_ev: f64,
555 pub mean_std_err_ev: f64,
557 pub variance_ev2: f64,
559 pub variance_std_err_ev2: f64,
561 pub p_zero: f64,
563 pub k_max: usize,
565 pub truncated_mass: f64,
567 pub bin_width_ev: f64,
569 pub overflow_fraction: f64,
571 pub distribution: Option<CompoundDistribution>,
573 pub note: Option<String>,
575}
576
577fn distribution_atom_at_zero(r: &CompoundRequest) -> CompoundDistribution {
578 let b = |v: f64| Bracket {
579 low: v,
580 central: v,
581 high: v,
582 };
583 CompoundDistribution {
584 quantiles: r
585 .quantiles
586 .iter()
587 .map(|&p| QuantileResult {
588 p,
589 energy_ev: b(0.0),
590 })
591 .collect(),
592 below: r
593 .below_ev
594 .iter()
595 .map(|&t| ThresholdResult {
596 threshold_ev: t,
597 probability: b(if t > 0.0 { 1.0 } else { 0.0 }),
598 })
599 .collect(),
600 above: r
601 .above_ev
602 .iter()
603 .map(|&t| ThresholdResult {
604 threshold_ev: t,
605 probability: b(if t < 0.0 { 1.0 } else { 0.0 }),
606 })
607 .collect(),
608 }
609}
610
611fn poisson_pmf(mu: f64, tol: f64) -> (Vec<f64>, usize, f64) {
614 let mut p = vec![(-mu).exp()];
615 let mut cum = p[0];
616 let mut k = 0usize;
617 while 1.0 - cum > tol && k < 1_000_000 {
619 k += 1;
620 let next = p[k - 1] * mu / k as f64;
621 p.push(next);
622 cum += next;
623 if next == 0.0 && k as f64 > mu {
624 break;
625 }
626 }
627 (p, k, (1.0 - cum).max(0.0))
628}
629
630fn summarise(
632 w: f64,
633 lo: &[f64],
634 mid: &[f64],
635 hi: &[f64],
636 r: &CompoundRequest,
637) -> CompoundDistribution {
638 let half = w / 2.0;
640 let cdf = |a: &[f64]| -> Vec<f64> {
641 let mut c = 0.0;
642 a.iter()
643 .map(|&m| {
644 c += m;
645 c
646 })
647 .collect()
648 };
649 let (cl, cm, ch) = (cdf(lo), cdf(mid), cdf(hi));
650 let quantile = |c: &[f64], p: f64| -> f64 {
651 let i = c.iter().position(|&v| v >= p).unwrap_or(c.len() - 1);
652 i as f64 * half
653 };
654 let below = |c: &[f64], t: f64| -> f64 {
657 if t <= 0.0 {
658 return 0.0;
659 }
660 let n = (t / half).ceil() as usize; if n == 0 {
663 0.0
664 } else {
665 c[(n - 1).min(c.len() - 1)]
666 }
667 };
668 let le = |c: &[f64], t: f64| -> f64 {
669 if t < 0.0 {
670 return 0.0;
671 }
672 let i = (t / half).floor() as usize;
673 c[i.min(c.len() - 1)]
674 };
675 CompoundDistribution {
676 quantiles: r
677 .quantiles
678 .iter()
679 .map(|&p| QuantileResult {
680 p,
681 energy_ev: Bracket {
682 low: quantile(&cl, p),
683 central: quantile(&cm, p),
684 high: quantile(&ch, p),
685 },
686 })
687 .collect(),
688 below: r
689 .below_ev
690 .iter()
691 .map(|&t| ThresholdResult {
692 threshold_ev: t,
693 probability: Bracket {
694 low: below(&cl, t),
695 central: below(&cm, t),
696 high: below(&ch, t),
697 },
698 })
699 .collect(),
700 above: r
701 .above_ev
702 .iter()
703 .map(|&t| ThresholdResult {
704 threshold_ev: t,
705 probability: Bracket {
706 low: 1.0 - le(&cl, t),
707 central: 1.0 - le(&cm, t),
708 high: 1.0 - le(&ch, t),
709 },
710 })
711 .collect(),
712 }
713}
714
715#[derive(Debug, Clone, PartialEq)]
720pub struct ExposureAccumulator {
721 config: ExposureConfig,
722 hists: Vec<ExposureHistogram>,
723 scratch: Vec<f64>,
725 shift: [f64; 2],
727 origin: [f64; 2],
728}
729
730impl ExposureAccumulator {
731 pub fn new(config: ExposureConfig) -> Result<Self, ExposureError> {
733 config.validate()?;
734 let n = config.volumes.len();
735 let hists = (0..n)
736 .map(|_| ExposureHistogram::new(config.bins, config.x_max_ev))
737 .collect();
738 Ok(Self {
739 config,
740 hists,
741 scratch: vec![0.0; n],
742 shift: [0.0; 2],
743 origin: [0.0; 2],
744 })
745 }
746
747 pub fn config(&self) -> &ExposureConfig {
749 &self.config
750 }
751
752 pub fn begin_history(&mut self, index: u64, start_yz: [f64; 2]) {
755 self.shift = self.config.beam.offset(self.config.seed, index);
756 self.origin = start_yz;
757 self.scratch.iter_mut().for_each(|s| *s = 0.0);
758 }
759
760 pub fn deposit(&mut self, pos: [f64; 3], e: f64) {
762 if !(e.is_finite() && e > 0.0) {
763 return;
764 }
765 let dy = pos[1] - self.origin[0] + self.shift[0];
766 let dz = pos[2] - self.origin[1] + self.shift[1];
767 for (v, s) in self.config.volumes.iter().zip(&mut self.scratch) {
768 if v.contains(pos[0], dy, dz) {
769 *s += e;
770 }
771 }
772 }
773
774 pub fn end_history(&mut self) {
776 for (h, s) in self.hists.iter_mut().zip(&mut self.scratch) {
777 h.push(*s);
778 *s = 0.0;
779 }
780 }
781
782 pub fn merge(&mut self, o: &Self) {
787 assert!(
788 self.config == o.config,
789 "merging exposure accumulators with different configurations"
790 );
791 for (a, b) in self.hists.iter_mut().zip(&o.hists) {
792 a.merge(b);
793 }
794 }
795
796 pub fn report(&self) -> ExposureReport {
798 let request = CompoundRequest {
799 quantiles: self.config.quantiles.clone(),
800 below_ev: self.config.below_ev.clone(),
801 above_ev: self.config.above_ev.clone(),
802 tail_tolerance: self.config.tail_tolerance,
803 };
804 let volumes = self
805 .config
806 .volumes
807 .iter()
808 .zip(&self.hists)
809 .map(|(v, h)| {
810 let (compound, error) = match h.compound_poisson(self.config.lambda, &request) {
811 Ok(c) => (Some(c), None),
812 Err(e) => (None, Some(e.to_string())),
813 };
814 ExposureVolumeReport {
815 volume: v.clone(),
816 mean_ev: h.mean_ev(),
817 mean_std_err_ev: h.mean_std_err_ev(),
818 second_moment_ev2: h.second_moment_ev2(),
819 second_moment_std_err_ev2: h.second_moment_std_err_ev2(),
820 zero_fraction: h.zero_fraction(),
821 histogram: h.clone(),
822 compound,
823 compound_error: error,
824 }
825 })
826 .collect();
827 ExposureReport {
828 lambda: self.config.lambda,
829 beam: self.config.beam,
830 volumes,
831 }
832 }
833}
834
835#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
837pub struct ExposureVolumeReport {
838 pub volume: ExposureVolume,
840 pub mean_ev: f64,
842 pub mean_std_err_ev: f64,
844 pub second_moment_ev2: f64,
846 pub second_moment_std_err_ev2: f64,
848 pub zero_fraction: f64,
850 pub histogram: ExposureHistogram,
852 pub compound: Option<CompoundPoisson>,
854 pub compound_error: Option<String>,
856}
857
858#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
860pub struct ExposureReport {
861 pub lambda: f64,
863 pub beam: BeamProfile,
865 pub volumes: Vec<ExposureVolumeReport>,
867}
868
869impl ExposureReport {
870 pub fn histogram_csv(&self) -> String {
873 let mut s = String::from("volume,bin,lo_ev,hi_ev,count,fraction\n");
874 for v in &self.volumes {
875 let h = &v.histogram;
876 let n = h.histories.max(1) as f64;
877 s += &format!(
878 "{},0,0,0,{},{}\n",
879 v.volume.name,
880 h.zero_count,
881 h.zero_count as f64 / n
882 );
883 for (i, &c) in h.counts.iter().enumerate() {
884 s += &format!(
885 "{},{},{},{},{},{}\n",
886 v.volume.name,
887 i + 1,
888 i as f64 * h.bin_width_ev,
889 (i + 1) as f64 * h.bin_width_ev,
890 c,
891 c as f64 / n
892 );
893 }
894 }
895 s
896 }
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902
903 fn req() -> CompoundRequest {
904 CompoundRequest {
905 quantiles: vec![0.5],
906 below_ev: vec![],
907 above_ev: vec![],
908 tail_tolerance: 1e-14,
909 }
910 }
911
912 #[test]
915 fn thinned_poisson_matches_the_closed_form() {
916 let mut h = ExposureHistogram::new(1, 1.0);
917 for i in 0..100 {
918 h.push(if i < 30 { 1.0 } else { 0.0 });
919 }
920 let lambda = 4.0;
921 let r = CompoundRequest {
922 quantiles: vec![0.5, 0.9],
923 below_ev: vec![3.0],
924 above_ev: vec![5.0],
925 tail_tolerance: 1e-15,
926 };
927 let c = h.compound_poisson(lambda, &r).unwrap();
928 let mu: f64 = lambda * 0.3;
929 let pmf = |k: u32| (-mu).exp() * mu.powi(k as i32) / (1..=k).product::<u32>() as f64;
931 let d = c.distribution.unwrap();
932 let below3: f64 = (0..3).map(pmf).sum();
933 let above5: f64 = 1.0 - (0..=5).map(pmf).sum::<f64>();
934 assert!((d.below[0].probability.high - below3).abs() < 1e-12);
936 assert!((d.above[0].probability.high - above5).abs() < 1e-12);
937 assert!((c.p_zero - (-mu).exp()).abs() < 1e-15);
938 assert!((c.mean_ev - lambda * 0.3).abs() < 1e-12);
939 assert!((c.variance_ev2 - lambda * 0.3).abs() < 1e-12);
940 assert_eq!(d.quantiles[0].energy_ev.high, 1.0);
942 assert_eq!(d.quantiles[0].energy_ev.low, 0.0);
943 }
944
945 #[test]
946 fn empty_volume_is_an_atom_at_zero() {
947 let mut h = ExposureHistogram::new(4, 4.0);
948 for _ in 0..10 {
949 h.push(0.0);
950 }
951 let mut r = req();
952 r.below_ev = vec![1.0];
953 r.above_ev = vec![0.0];
954 let c = h.compound_poisson(7.0, &r).unwrap();
955 assert_eq!(c.mean_ev, 0.0);
956 let d = c.distribution.unwrap();
957 assert_eq!(d.quantiles[0].energy_ev.central, 0.0);
958 assert_eq!(d.below[0].probability.central, 1.0);
959 assert_eq!(d.above[0].probability.central, 0.0);
960 }
961
962 #[test]
963 fn brackets_order_and_moments_are_consistent() {
964 let mut h = ExposureHistogram::new(10, 10.0);
965 for i in 0..1000u32 {
966 h.push(if i % 3 == 0 {
967 0.0
968 } else {
969 (i % 17) as f64 * 0.5
970 });
971 }
972 let mut r = req();
973 r.quantiles = vec![0.1, 0.5, 0.9];
974 r.above_ev = vec![8.0];
975 let c = h.compound_poisson(3.0, &r).unwrap();
976 let d = c.distribution.unwrap();
977 for q in &d.quantiles {
978 let b = q.energy_ev;
979 assert!(b.low <= b.central && b.central <= b.high);
980 }
981 let b = d.above[0].probability;
982 assert!(b.low <= b.central && b.central <= b.high);
983 assert!(c.truncated_mass <= 1e-14);
985 }
986
987 #[test]
988 fn config_validation_rejects_bad_scalars() {
989 let v = ExposureVolume {
990 name: "a".into(),
991 shape: VolumeShape::Cylinder { radius_m: 1e-8 },
992 depth_lo_m: 0.0,
993 depth_hi_m: 1e-8,
994 offset_y_m: 0.0,
995 offset_z_m: 0.0,
996 };
997 let ok = ExposureConfig {
998 volumes: vec![v],
999 beam: BeamProfile::Point,
1000 seed: 1,
1001 bins: 10,
1002 x_max_ev: 100.0,
1003 lambda: 1.0,
1004 quantiles: vec![0.5],
1005 below_ev: vec![],
1006 above_ev: vec![],
1007 tail_tolerance: 1e-12,
1008 };
1009 assert!(ok.validate().is_ok());
1010 let mut b = ok.clone();
1011 b.lambda = 0.0;
1012 assert!(b.validate().is_err());
1013 let mut b = ok.clone();
1014 b.volumes.clear();
1015 assert!(b.validate().is_err());
1016 let mut b = ok;
1017 b.quantiles = vec![1.0];
1018 assert!(b.validate().is_err());
1019 }
1020
1021 #[test]
1022 fn beam_offsets_are_deterministic_and_on_the_disc() {
1023 let b = BeamProfile::UniformDisc { radius_m: 2.0 };
1024 assert_eq!(b.offset(5, 9), b.offset(5, 9));
1025 for i in 0..200 {
1026 let [y, z] = b.offset(5, i);
1027 assert!(y.hypot(z) <= 2.0);
1028 }
1029 assert_eq!(BeamProfile::Point.offset(1, 1), [0.0, 0.0]);
1030 }
1031}