1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::f64::consts::PI;
7
8pub mod prelude;
9
10pub const SPEED_OF_LIGHT: f64 = 299_792_458.0;
14
15pub const PLANCK_CONSTANT: f64 = 6.626_070_15e-34;
19
20pub const ELEMENTARY_CHARGE: f64 = 1.602_176_634e-19;
24
25pub const JOULES_PER_MEV: f64 = 1.602_176_634e-13;
29
30fn is_non_negative_finite(value: f64) -> bool {
31 value.is_finite() && value >= 0.0
32}
33
34fn is_positive_finite(value: f64) -> bool {
35 value.is_finite() && value > 0.0
36}
37
38fn normalize_zero(value: f64) -> f64 {
39 if value == 0.0 { 0.0 } else { value }
40}
41
42fn finite_non_negative(value: f64) -> Option<f64> {
43 (value.is_finite() && value >= 0.0).then_some(normalize_zero(value))
44}
45
46fn finite_unit_interval(value: f64) -> Option<f64> {
47 (value.is_finite() && (0.0..=1.0).contains(&value)).then_some(normalize_zero(value))
48}
49
50fn divide_non_negative(numerator: f64, denominator: f64) -> Option<f64> {
51 if !is_non_negative_finite(numerator) || !is_positive_finite(denominator) {
52 return None;
53 }
54
55 finite_non_negative(numerator / denominator)
56}
57
58fn multiply_non_negative(left: f64, right: f64) -> Option<f64> {
59 if !is_non_negative_finite(left) || !is_non_negative_finite(right) {
60 return None;
61 }
62
63 finite_non_negative(left * right)
64}
65
66#[must_use]
72pub fn photon_energy_from_frequency(frequency: f64) -> Option<f64> {
73 if !is_non_negative_finite(frequency) {
74 return None;
75 }
76
77 finite_non_negative(PLANCK_CONSTANT * frequency)
78}
79
80#[must_use]
84pub fn photon_energy_from_wavelength(wavelength: f64) -> Option<f64> {
85 if !is_positive_finite(wavelength) {
86 return None;
87 }
88
89 finite_non_negative((PLANCK_CONSTANT * SPEED_OF_LIGHT) / wavelength)
90}
91
92#[must_use]
104pub fn photon_flux_from_power(power: f64, photon_energy: f64) -> Option<f64> {
105 divide_non_negative(power, photon_energy)
106}
107
108#[must_use]
112pub fn photon_flux_density(photon_flux: f64, area: f64) -> Option<f64> {
113 divide_non_negative(photon_flux, area)
114}
115
116#[must_use]
128pub fn intensity(power: f64, area: f64) -> Option<f64> {
129 divide_non_negative(power, area)
130}
131
132#[must_use]
146pub fn isotropic_intensity(power: f64, distance: f64) -> Option<f64> {
147 if !is_non_negative_finite(power) || !is_positive_finite(distance) {
148 return None;
149 }
150
151 intensity(power, 4.0 * PI * distance * distance)
152}
153
154#[must_use]
158pub fn inverse_square_intensity(
159 reference_intensity: f64,
160 reference_distance: f64,
161 target_distance: f64,
162) -> Option<f64> {
163 if !is_non_negative_finite(reference_intensity)
164 || !is_positive_finite(reference_distance)
165 || !is_positive_finite(target_distance)
166 {
167 return None;
168 }
169
170 let ratio = reference_distance / target_distance;
171 finite_non_negative(reference_intensity * ratio * ratio)
172}
173
174#[must_use]
178pub fn fluence(particle_count: f64, area: f64) -> Option<f64> {
179 divide_non_negative(particle_count, area)
180}
181
182#[must_use]
186pub fn energy_fluence(energy: f64, area: f64) -> Option<f64> {
187 divide_non_negative(energy, area)
188}
189
190#[must_use]
192pub fn fluence_rate(fluence: f64, time: f64) -> Option<f64> {
193 divide_non_negative(fluence, time)
194}
195
196#[must_use]
208pub fn absorbed_dose(energy_absorbed: f64, mass: f64) -> Option<f64> {
209 divide_non_negative(energy_absorbed, mass)
210}
211
212#[must_use]
214pub fn absorbed_energy_from_dose(dose: f64, mass: f64) -> Option<f64> {
215 multiply_non_negative(dose, mass)
216}
217
218#[must_use]
220pub fn dose_rate(dose: f64, time: f64) -> Option<f64> {
221 divide_non_negative(dose, time)
222}
223
224#[must_use]
226pub fn accumulated_dose(dose_rate: f64, time: f64) -> Option<f64> {
227 multiply_non_negative(dose_rate, time)
228}
229
230#[must_use]
242pub fn equivalent_dose(absorbed_dose: f64, radiation_weighting_factor: f64) -> Option<f64> {
243 multiply_non_negative(absorbed_dose, radiation_weighting_factor)
244}
245
246#[must_use]
250pub fn effective_dose(equivalent_dose: f64, tissue_weighting_factor: f64) -> Option<f64> {
251 multiply_non_negative(equivalent_dose, tissue_weighting_factor)
252}
253
254#[must_use]
256pub fn total_effective_dose(weighted_equivalent_doses: &[f64]) -> Option<f64> {
257 weighted_equivalent_doses
258 .iter()
259 .try_fold(0.0, |sum, value| {
260 if !is_non_negative_finite(*value) {
261 return None;
262 }
263
264 finite_non_negative(sum + *value)
265 })
266}
267
268#[must_use]
282pub fn attenuated_intensity(
283 initial_intensity: f64,
284 linear_attenuation_coefficient: f64,
285 thickness: f64,
286) -> Option<f64> {
287 if !is_non_negative_finite(initial_intensity)
288 || !is_non_negative_finite(linear_attenuation_coefficient)
289 || !is_non_negative_finite(thickness)
290 {
291 return None;
292 }
293
294 finite_non_negative(
295 initial_intensity * transmitted_fraction(linear_attenuation_coefficient, thickness)?,
296 )
297}
298
299#[must_use]
301pub fn transmitted_fraction(linear_attenuation_coefficient: f64, thickness: f64) -> Option<f64> {
302 if !is_non_negative_finite(linear_attenuation_coefficient) || !is_non_negative_finite(thickness)
303 {
304 return None;
305 }
306
307 finite_unit_interval((-(linear_attenuation_coefficient * thickness)).exp())
308}
309
310#[must_use]
312pub fn required_shield_thickness(
313 linear_attenuation_coefficient: f64,
314 initial_intensity: f64,
315 target_intensity: f64,
316) -> Option<f64> {
317 if !is_positive_finite(linear_attenuation_coefficient)
318 || !is_positive_finite(initial_intensity)
319 || !is_positive_finite(target_intensity)
320 || target_intensity > initial_intensity
321 {
322 return None;
323 }
324
325 finite_non_negative(
326 (initial_intensity / target_intensity).ln() / linear_attenuation_coefficient,
327 )
328}
329
330#[must_use]
344pub fn half_value_layer(linear_attenuation_coefficient: f64) -> Option<f64> {
345 if !is_positive_finite(linear_attenuation_coefficient) {
346 return None;
347 }
348
349 finite_non_negative(core::f64::consts::LN_2 / linear_attenuation_coefficient)
350}
351
352#[must_use]
354pub fn tenth_value_layer(linear_attenuation_coefficient: f64) -> Option<f64> {
355 if !is_positive_finite(linear_attenuation_coefficient) {
356 return None;
357 }
358
359 finite_non_negative(core::f64::consts::LN_10 / linear_attenuation_coefficient)
360}
361
362#[must_use]
366pub fn linear_attenuation_from_mass_attenuation(
367 mass_attenuation_coefficient: f64,
368 density: f64,
369) -> Option<f64> {
370 multiply_non_negative(mass_attenuation_coefficient, density)
371}
372
373#[must_use]
377pub fn mass_attenuation_from_linear_attenuation(
378 linear_attenuation_coefficient: f64,
379 density: f64,
380) -> Option<f64> {
381 divide_non_negative(linear_attenuation_coefficient, density)
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
386pub enum RadiationKind {
387 Alpha,
389 BetaMinus,
391 BetaPlus,
393 Gamma,
395 XRay,
397 Neutron,
399 Proton,
401 Electron,
403 Photon,
405}
406
407#[must_use]
409pub const fn is_ionizing(_kind: RadiationKind) -> bool {
410 true
411}
412
413#[must_use]
415pub const fn is_photon_radiation(kind: RadiationKind) -> bool {
416 matches!(
417 kind,
418 RadiationKind::Gamma | RadiationKind::XRay | RadiationKind::Photon
419 )
420}
421
422#[must_use]
424pub const fn is_particle_radiation(kind: RadiationKind) -> bool {
425 !is_photon_radiation(kind)
426}
427
428#[must_use]
433pub const fn default_radiation_weighting_factor(kind: RadiationKind) -> Option<f64> {
434 match kind {
435 RadiationKind::Gamma
436 | RadiationKind::XRay
437 | RadiationKind::BetaMinus
438 | RadiationKind::BetaPlus
439 | RadiationKind::Electron
440 | RadiationKind::Photon => Some(1.0),
441 RadiationKind::Proton => Some(2.0),
442 RadiationKind::Alpha => Some(20.0),
443 RadiationKind::Neutron => None,
444 }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq)]
449pub struct RadiationBeam {
450 pub power: f64,
452 pub area: f64,
454}
455
456impl RadiationBeam {
457 #[must_use]
459 pub fn new(power: f64, area: f64) -> Option<Self> {
460 if !is_non_negative_finite(power) || !is_positive_finite(area) {
461 return None;
462 }
463
464 Some(Self { power, area })
465 }
466
467 #[must_use]
482 pub fn intensity(&self) -> Option<f64> {
483 intensity(self.power, self.area)
484 }
485
486 #[must_use]
488 pub fn photon_flux(&self, photon_energy: f64) -> Option<f64> {
489 photon_flux_from_power(self.power, photon_energy)
490 }
491
492 #[must_use]
494 pub fn photon_flux_density(&self, photon_energy: f64) -> Option<f64> {
495 photon_flux_density(self.photon_flux(photon_energy)?, self.area)
496 }
497}
498
499#[derive(Debug, Clone, Copy, PartialEq)]
501pub struct Shield {
502 pub linear_attenuation_coefficient: f64,
504 pub thickness: f64,
506}
507
508impl Shield {
509 #[must_use]
511 pub fn new(linear_attenuation_coefficient: f64, thickness: f64) -> Option<Self> {
512 if !is_non_negative_finite(linear_attenuation_coefficient)
513 || !is_non_negative_finite(thickness)
514 {
515 return None;
516 }
517
518 Some(Self {
519 linear_attenuation_coefficient,
520 thickness,
521 })
522 }
523
524 #[must_use]
526 pub fn transmitted_fraction(&self) -> Option<f64> {
527 transmitted_fraction(self.linear_attenuation_coefficient, self.thickness)
528 }
529
530 #[must_use]
548 pub fn attenuated_intensity(&self, initial_intensity: f64) -> Option<f64> {
549 attenuated_intensity(
550 initial_intensity,
551 self.linear_attenuation_coefficient,
552 self.thickness,
553 )
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq)]
559pub struct Dose {
560 pub absorbed_dose: f64,
562}
563
564impl Dose {
565 #[must_use]
567 pub fn new(absorbed_dose: f64) -> Option<Self> {
568 if !is_non_negative_finite(absorbed_dose) {
569 return None;
570 }
571
572 Some(Self { absorbed_dose })
573 }
574
575 #[must_use]
590 pub fn equivalent(self, radiation_weighting_factor: f64) -> Option<f64> {
591 equivalent_dose(self.absorbed_dose, radiation_weighting_factor)
592 }
593
594 #[must_use]
596 pub fn rate_over(self, time: f64) -> Option<f64> {
597 dose_rate(self.absorbed_dose, time)
598 }
599}
600
601#[cfg(test)]
602#[allow(clippy::float_cmp)]
603mod tests {
604 use super::{
605 Dose, ELEMENTARY_CHARGE, JOULES_PER_MEV, PLANCK_CONSTANT, RadiationBeam, RadiationKind,
606 SPEED_OF_LIGHT, Shield, absorbed_dose, absorbed_energy_from_dose, accumulated_dose,
607 attenuated_intensity, default_radiation_weighting_factor, dose_rate, effective_dose,
608 energy_fluence, equivalent_dose, fluence, fluence_rate, half_value_layer, intensity,
609 inverse_square_intensity, is_ionizing, is_particle_radiation, is_photon_radiation,
610 isotropic_intensity, linear_attenuation_from_mass_attenuation,
611 mass_attenuation_from_linear_attenuation, photon_energy_from_frequency,
612 photon_energy_from_wavelength, photon_flux_density, photon_flux_from_power,
613 required_shield_thickness, tenth_value_layer, total_effective_dose, transmitted_fraction,
614 };
615
616 fn assert_approx_eq(left: f64, right: f64) {
617 let tolerance = 1.0e-12 * left.abs().max(right.abs()).max(1.0);
618 assert!(
619 (left - right).abs() <= tolerance,
620 "left={left}, right={right}, tolerance={tolerance}"
621 );
622 }
623
624 fn assert_some_approx(actual: Option<f64>, expected: f64) {
625 let Some(actual) = actual else {
626 panic!("expected Some({expected})");
627 };
628
629 assert_approx_eq(actual, expected);
630 }
631
632 #[test]
633 fn photon_energy_helpers_validate_inputs() {
634 assert_eq!(photon_energy_from_frequency(1.0), Some(PLANCK_CONSTANT));
635 assert_eq!(photon_energy_from_frequency(-1.0), None);
636 assert_eq!(photon_energy_from_frequency(f64::NAN), None);
637
638 assert_some_approx(
639 photon_energy_from_wavelength(SPEED_OF_LIGHT),
640 PLANCK_CONSTANT,
641 );
642 assert_eq!(photon_energy_from_wavelength(0.0), None);
643 assert_eq!(photon_energy_from_wavelength(f64::INFINITY), None);
644 }
645
646 #[test]
647 fn photon_flux_helpers_cover_common_cases() {
648 assert_eq!(photon_flux_from_power(10.0, 2.0), Some(5.0));
649 assert_eq!(photon_flux_from_power(10.0, 0.0), None);
650 assert_eq!(photon_flux_from_power(-1.0, 2.0), None);
651
652 assert_eq!(photon_flux_density(10.0, 2.0), Some(5.0));
653 assert_eq!(photon_flux_density(10.0, 0.0), None);
654 }
655
656 #[test]
657 fn intensity_helpers_cover_planar_and_inverse_square_cases() {
658 assert_eq!(intensity(10.0, 2.0), Some(5.0));
659 assert_eq!(intensity(-10.0, 2.0), None);
660 assert_eq!(intensity(10.0, 0.0), None);
661
662 assert_some_approx(isotropic_intensity(4.0 * core::f64::consts::PI, 1.0), 1.0);
663 assert_eq!(isotropic_intensity(1.0, 0.0), None);
664
665 assert_eq!(inverse_square_intensity(100.0, 1.0, 2.0), Some(25.0));
666 assert_eq!(inverse_square_intensity(100.0, 1.0, 0.0), None);
667 }
668
669 #[test]
670 fn fluence_helpers_validate_inputs() {
671 assert_eq!(fluence(100.0, 2.0), Some(50.0));
672 assert_eq!(fluence(-100.0, 2.0), None);
673
674 assert_eq!(energy_fluence(100.0, 2.0), Some(50.0));
675 assert_eq!(fluence_rate(50.0, 10.0), Some(5.0));
676 assert_eq!(fluence_rate(50.0, 0.0), None);
677 }
678
679 #[test]
680 fn absorbed_dose_helpers_cover_forward_and_inverse_relations() {
681 assert_eq!(absorbed_dose(20.0, 4.0), Some(5.0));
682 assert_eq!(absorbed_dose(20.0, 0.0), None);
683
684 assert_eq!(absorbed_energy_from_dose(5.0, 4.0), Some(20.0));
685 assert_eq!(absorbed_energy_from_dose(-5.0, 4.0), None);
686
687 assert_eq!(dose_rate(10.0, 2.0), Some(5.0));
688 assert_eq!(dose_rate(10.0, 0.0), None);
689
690 assert_eq!(accumulated_dose(5.0, 2.0), Some(10.0));
691 assert_eq!(accumulated_dose(5.0, -1.0), None);
692 }
693
694 #[test]
695 fn equivalent_and_effective_dose_helpers_cover_common_cases() {
696 assert_eq!(equivalent_dose(2.0, 20.0), Some(40.0));
697 assert_eq!(equivalent_dose(-2.0, 20.0), None);
698
699 assert_some_approx(effective_dose(10.0, 0.12), 1.2);
700 assert_eq!(effective_dose(10.0, -0.12), None);
701
702 assert_eq!(total_effective_dose(&[1.0, 2.0, 3.0]), Some(6.0));
703 assert_eq!(total_effective_dose(&[]), Some(0.0));
704 assert_eq!(total_effective_dose(&[1.0, -2.0]), None);
705 }
706
707 #[test]
708 fn attenuation_helpers_cover_common_shielding_cases() {
709 assert_some_approx(
710 attenuated_intensity(100.0, core::f64::consts::LN_2, 1.0),
711 50.0,
712 );
713 assert_eq!(attenuated_intensity(100.0, -1.0, 1.0), None);
714
715 assert_some_approx(transmitted_fraction(core::f64::consts::LN_2, 1.0), 0.5);
716 assert_eq!(transmitted_fraction(-1.0, 1.0), None);
717
718 assert_some_approx(
719 required_shield_thickness(core::f64::consts::LN_2, 100.0, 50.0),
720 1.0,
721 );
722 assert_eq!(
723 required_shield_thickness(core::f64::consts::LN_2, 100.0, 200.0),
724 None,
725 );
726
727 assert_some_approx(half_value_layer(core::f64::consts::LN_2), 1.0);
728 assert_eq!(half_value_layer(0.0), None);
729
730 assert_some_approx(tenth_value_layer(core::f64::consts::LN_10), 1.0);
731 assert_eq!(tenth_value_layer(0.0), None);
732 }
733
734 #[test]
735 fn attenuation_conversion_helpers_cover_mass_and_linear_forms() {
736 assert_eq!(
737 linear_attenuation_from_mass_attenuation(2.0, 3.0),
738 Some(6.0)
739 );
740 assert_eq!(linear_attenuation_from_mass_attenuation(-2.0, 3.0), None);
741
742 assert_eq!(
743 mass_attenuation_from_linear_attenuation(6.0, 3.0),
744 Some(2.0)
745 );
746 assert_eq!(mass_attenuation_from_linear_attenuation(6.0, 0.0), None);
747 }
748
749 #[test]
750 fn radiation_kind_helpers_cover_simple_classification() {
751 assert!(is_ionizing(RadiationKind::Alpha));
752 assert!(is_photon_radiation(RadiationKind::Gamma));
753 assert!(!is_photon_radiation(RadiationKind::Alpha));
754 assert!(is_particle_radiation(RadiationKind::Alpha));
755 assert!(!is_particle_radiation(RadiationKind::Photon));
756
757 assert_eq!(
758 default_radiation_weighting_factor(RadiationKind::Gamma),
759 Some(1.0)
760 );
761 assert_eq!(
762 default_radiation_weighting_factor(RadiationKind::Alpha),
763 Some(20.0)
764 );
765 assert_eq!(
766 default_radiation_weighting_factor(RadiationKind::Neutron),
767 None
768 );
769 }
770
771 #[test]
772 fn simple_types_delegate_to_public_helpers() {
773 let Some(beam) = RadiationBeam::new(10.0, 2.0) else {
774 panic!("expected valid beam");
775 };
776 assert_eq!(beam.intensity(), Some(5.0));
777 assert_eq!(beam.photon_flux(2.0), Some(5.0));
778 assert_eq!(RadiationBeam::new(10.0, 0.0), None);
779
780 let Some(shield) = Shield::new(core::f64::consts::LN_2, 1.0) else {
781 panic!("expected valid shield");
782 };
783 assert_some_approx(shield.transmitted_fraction(), 0.5);
784 assert_some_approx(shield.attenuated_intensity(100.0), 50.0);
785 assert_eq!(Shield::new(-1.0, 1.0), None);
786
787 let Some(dose) = Dose::new(2.0) else {
788 panic!("expected valid dose");
789 };
790 assert_eq!(dose.equivalent(20.0), Some(40.0));
791 assert_eq!(
792 Dose::new(10.0).and_then(|value| value.rate_over(2.0)),
793 Some(5.0)
794 );
795 assert_eq!(Dose::new(-1.0), None);
796 }
797
798 #[test]
799 fn local_constants_match_expected_values() {
800 assert_eq!(SPEED_OF_LIGHT, 299_792_458.0);
801 assert_eq!(PLANCK_CONSTANT, 6.626_070_15e-34);
802 assert_eq!(ELEMENTARY_CHARGE, 1.602_176_634e-19);
803 assert_eq!(JOULES_PER_MEV, 1.602_176_634e-13);
804 }
805
806 #[test]
807 fn non_finite_inputs_return_none() {
808 assert_eq!(intensity(f64::NAN, 2.0), None);
809 assert_eq!(photon_flux_from_power(1.0, f64::INFINITY), None);
810 assert_eq!(required_shield_thickness(1.0, f64::INFINITY, 1.0), None);
811 }
812}