1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4pub mod prelude;
7
8const SPEED_OF_LIGHT_SQUARED: f64 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
9
10pub const LN_2: f64 = std::f64::consts::LN_2;
14
15pub const SPEED_OF_LIGHT: f64 = 299_792_458.0;
19
20pub const JOULES_PER_MEV: f64 = 1.602_176_634e-13;
24
25pub const ATOMIC_MASS_UNIT_MEV_C2: f64 = 931.494_102_42;
29
30#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct DecayLaw {
33 pub decay_constant: f64,
35}
36
37impl DecayLaw {
38 #[must_use]
40 pub fn from_decay_constant(decay_constant: f64) -> Option<Self> {
41 if is_non_negative_finite(decay_constant) {
42 Some(Self { decay_constant })
43 } else {
44 None
45 }
46 }
47
48 #[must_use]
50 pub fn from_half_life(half_life: f64) -> Option<Self> {
51 Self::from_decay_constant(decay_constant_from_half_life(half_life)?)
52 }
53
54 #[must_use]
56 pub fn half_life(&self) -> Option<f64> {
57 half_life_from_decay_constant(self.decay_constant)
58 }
59
60 #[must_use]
62 pub fn mean_lifetime(&self) -> Option<f64> {
63 mean_lifetime(self.decay_constant)
64 }
65
66 #[must_use]
68 pub fn remaining_fraction(&self, time: f64) -> Option<f64> {
69 remaining_fraction_from_decay_constant(self.decay_constant, time)
70 }
71
72 #[must_use]
90 pub fn remaining_quantity(&self, initial_quantity: f64, time: f64) -> Option<f64> {
91 remaining_quantity_from_decay_constant(initial_quantity, self.decay_constant, time)
92 }
93
94 #[must_use]
96 pub fn decayed_quantity(&self, initial_quantity: f64, time: f64) -> Option<f64> {
97 decayed_quantity_from_decay_constant(initial_quantity, self.decay_constant, time)
98 }
99
100 #[must_use]
102 pub fn activity(&self, number_of_nuclei: f64) -> Option<f64> {
103 activity(self.decay_constant, number_of_nuclei)
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct NuclideNumbers {
110 pub mass_number: u32,
112 pub atomic_number: u32,
114}
115
116impl NuclideNumbers {
117 #[must_use]
129 pub const fn new(mass_number: u32, atomic_number: u32) -> Option<Self> {
130 if is_valid_nuclide_numbers(mass_number, atomic_number) {
131 Some(Self {
132 mass_number,
133 atomic_number,
134 })
135 } else {
136 None
137 }
138 }
139
140 #[must_use]
142 pub const fn proton_count(&self) -> u32 {
143 self.atomic_number
144 }
145
146 #[must_use]
148 pub const fn neutron_count(&self) -> u32 {
149 self.mass_number - self.atomic_number
150 }
151
152 #[must_use]
154 pub const fn nucleon_count(&self) -> u32 {
155 self.mass_number
156 }
157}
158
159#[must_use]
180pub fn decay_constant_from_half_life(half_life: f64) -> Option<f64> {
181 if !is_positive_finite(half_life) {
182 return None;
183 }
184
185 finite_non_negative(LN_2 / half_life)
186}
187
188#[must_use]
192pub fn half_life_from_decay_constant(decay_constant: f64) -> Option<f64> {
193 if !is_positive_finite(decay_constant) {
194 return None;
195 }
196
197 finite_non_negative(LN_2 / decay_constant)
198}
199
200#[must_use]
204pub fn mean_lifetime(decay_constant: f64) -> Option<f64> {
205 if !is_positive_finite(decay_constant) {
206 return None;
207 }
208
209 finite_non_negative(1.0 / decay_constant)
210}
211
212#[must_use]
216pub fn remaining_fraction_from_decay_constant(decay_constant: f64, time: f64) -> Option<f64> {
217 if !is_non_negative_finite(decay_constant) || !is_non_negative_finite(time) {
218 return None;
219 }
220
221 finite_unit_interval((-(decay_constant * time)).exp())
222}
223
224#[must_use]
242pub fn remaining_fraction_from_half_life(half_life: f64, time: f64) -> Option<f64> {
243 if !is_positive_finite(half_life) || !is_non_negative_finite(time) {
244 return None;
245 }
246
247 finite_unit_interval((-(time / half_life)).exp2())
248}
249
250#[must_use]
254pub fn remaining_quantity_from_decay_constant(
255 initial_quantity: f64,
256 decay_constant: f64,
257 time: f64,
258) -> Option<f64> {
259 if !is_non_negative_finite(initial_quantity) {
260 return None;
261 }
262
263 finite_non_negative(
264 initial_quantity * remaining_fraction_from_decay_constant(decay_constant, time)?,
265 )
266}
267
268#[must_use]
270pub fn remaining_quantity_from_half_life(
271 initial_quantity: f64,
272 half_life: f64,
273 time: f64,
274) -> Option<f64> {
275 if !is_non_negative_finite(initial_quantity) {
276 return None;
277 }
278
279 finite_non_negative(initial_quantity * remaining_fraction_from_half_life(half_life, time)?)
280}
281
282#[must_use]
286pub fn decayed_fraction_from_decay_constant(decay_constant: f64, time: f64) -> Option<f64> {
287 finite_unit_interval(1.0 - remaining_fraction_from_decay_constant(decay_constant, time)?)
288}
289
290#[must_use]
294pub fn decayed_quantity_from_decay_constant(
295 initial_quantity: f64,
296 decay_constant: f64,
297 time: f64,
298) -> Option<f64> {
299 if !is_non_negative_finite(initial_quantity) {
300 return None;
301 }
302
303 finite_non_negative(
304 initial_quantity * decayed_fraction_from_decay_constant(decay_constant, time)?,
305 )
306}
307
308#[must_use]
312pub fn elapsed_time_from_remaining_fraction(
313 decay_constant: f64,
314 remaining_fraction: f64,
315) -> Option<f64> {
316 if !is_positive_finite(decay_constant)
317 || !remaining_fraction.is_finite()
318 || remaining_fraction <= 0.0
319 || remaining_fraction > 1.0
320 {
321 return None;
322 }
323
324 finite_non_negative(normalize_zero(-remaining_fraction.ln() / decay_constant))
325}
326
327#[must_use]
341pub fn activity(decay_constant: f64, number_of_nuclei: f64) -> Option<f64> {
342 if !is_non_negative_finite(decay_constant) || !is_non_negative_finite(number_of_nuclei) {
343 return None;
344 }
345
346 finite_non_negative(decay_constant * number_of_nuclei)
347}
348
349#[must_use]
353pub fn nuclei_from_activity(activity: f64, decay_constant: f64) -> Option<f64> {
354 if !is_non_negative_finite(activity) || !is_positive_finite(decay_constant) {
355 return None;
356 }
357
358 finite_non_negative(activity / decay_constant)
359}
360
361#[must_use]
365pub fn specific_activity(activity: f64, mass: f64) -> Option<f64> {
366 if !is_non_negative_finite(activity) || !is_positive_finite(mass) {
367 return None;
368 }
369
370 finite_non_negative(activity / mass)
371}
372
373#[must_use]
390pub fn energy_from_mass_defect_kg(mass_defect_kg: f64) -> Option<f64> {
391 if !is_non_negative_finite(mass_defect_kg) {
392 return None;
393 }
394
395 finite_non_negative(mass_defect_kg * SPEED_OF_LIGHT_SQUARED)
396}
397
398#[must_use]
402pub fn mass_defect_kg_from_energy(energy_joules: f64) -> Option<f64> {
403 if !is_non_negative_finite(energy_joules) {
404 return None;
405 }
406
407 finite_non_negative(energy_joules / SPEED_OF_LIGHT_SQUARED)
408}
409
410#[must_use]
412pub fn joules_to_mev(joules: f64) -> Option<f64> {
413 if !is_non_negative_finite(joules) {
414 return None;
415 }
416
417 finite_non_negative(joules / JOULES_PER_MEV)
418}
419
420#[must_use]
422pub fn mev_to_joules(mev: f64) -> Option<f64> {
423 if !is_non_negative_finite(mev) {
424 return None;
425 }
426
427 finite_non_negative(mev * JOULES_PER_MEV)
428}
429
430#[must_use]
448pub fn binding_energy_mev_from_mass_defect_u(mass_defect_u: f64) -> Option<f64> {
449 if !is_non_negative_finite(mass_defect_u) {
450 return None;
451 }
452
453 finite_non_negative(mass_defect_u * ATOMIC_MASS_UNIT_MEV_C2)
454}
455
456#[must_use]
460pub fn binding_energy_per_nucleon(binding_energy_mev: f64, nucleon_count: u32) -> Option<f64> {
461 if !is_non_negative_finite(binding_energy_mev) || nucleon_count == 0 {
462 return None;
463 }
464
465 finite_non_negative(binding_energy_mev / f64::from(nucleon_count))
466}
467
468#[must_use]
480pub const fn neutron_count(mass_number: u32, atomic_number: u32) -> Option<u32> {
481 mass_number.checked_sub(atomic_number)
482}
483
484#[must_use]
488pub const fn nucleon_count(proton_count: u32, neutron_count: u32) -> Option<u32> {
489 proton_count.checked_add(neutron_count)
490}
491
492#[must_use]
494pub const fn is_valid_nuclide_numbers(mass_number: u32, atomic_number: u32) -> bool {
495 mass_number != 0 && atomic_number != 0 && atomic_number <= mass_number
496}
497
498fn is_non_negative_finite(value: f64) -> bool {
499 value.is_finite() && value >= 0.0
500}
501
502fn is_positive_finite(value: f64) -> bool {
503 value.is_finite() && value > 0.0
504}
505
506fn finite_non_negative(value: f64) -> Option<f64> {
507 if value.is_finite() && value >= 0.0 {
508 Some(normalize_zero(value))
509 } else {
510 None
511 }
512}
513
514fn finite_unit_interval(value: f64) -> Option<f64> {
515 if value.is_finite() && (0.0..=1.0).contains(&value) {
516 Some(normalize_zero(value))
517 } else {
518 None
519 }
520}
521
522fn normalize_zero(value: f64) -> f64 {
523 if value == 0.0 { 0.0 } else { value }
524}
525
526#[cfg(test)]
527#[allow(clippy::float_cmp)]
528mod tests {
529 use super::{
530 ATOMIC_MASS_UNIT_MEV_C2, DecayLaw, JOULES_PER_MEV, LN_2, NuclideNumbers, SPEED_OF_LIGHT,
531 activity, binding_energy_mev_from_mass_defect_u, binding_energy_per_nucleon,
532 decay_constant_from_half_life, decayed_fraction_from_decay_constant,
533 decayed_quantity_from_decay_constant, elapsed_time_from_remaining_fraction,
534 energy_from_mass_defect_kg, half_life_from_decay_constant, is_valid_nuclide_numbers,
535 joules_to_mev, mass_defect_kg_from_energy, mean_lifetime, mev_to_joules, neutron_count,
536 nuclei_from_activity, nucleon_count, remaining_fraction_from_decay_constant,
537 remaining_fraction_from_half_life, remaining_quantity_from_decay_constant,
538 remaining_quantity_from_half_life, specific_activity,
539 };
540
541 fn assert_approx_eq(left: f64, right: f64) {
542 let tolerance = 1.0e-12 * left.abs().max(right.abs()).max(1.0);
543 assert!(
544 (left - right).abs() <= tolerance,
545 "left={left}, right={right}, tolerance={tolerance}"
546 );
547 }
548
549 fn assert_some_approx(actual: Option<f64>, expected: f64) {
550 let Some(actual) = actual else {
551 panic!("expected Some({expected})");
552 };
553
554 assert_approx_eq(actual, expected);
555 }
556
557 #[test]
558 fn decay_constant_and_half_life_helpers_cover_common_cases() {
559 assert_some_approx(decay_constant_from_half_life(10.0), LN_2 / 10.0);
560 assert_eq!(decay_constant_from_half_life(0.0), None);
561 assert_eq!(decay_constant_from_half_life(-1.0), None);
562
563 assert_some_approx(half_life_from_decay_constant(LN_2 / 10.0), 10.0);
564 assert_eq!(half_life_from_decay_constant(0.0), None);
565 }
566
567 #[test]
568 fn mean_lifetime_requires_positive_decay_constant() {
569 assert_eq!(mean_lifetime(2.0), Some(0.5));
570 assert_eq!(mean_lifetime(0.0), None);
571 }
572
573 #[test]
574 fn remaining_fraction_and_quantity_helpers_follow_decay_law() {
575 assert_eq!(remaining_fraction_from_decay_constant(0.0, 10.0), Some(1.0));
576 assert_some_approx(remaining_fraction_from_half_life(10.0, 10.0), 0.5);
577 assert_some_approx(remaining_fraction_from_half_life(10.0, 20.0), 0.25);
578 assert_eq!(remaining_fraction_from_half_life(0.0, 10.0), None);
579 assert_eq!(remaining_fraction_from_half_life(10.0, -1.0), None);
580
581 assert_some_approx(remaining_quantity_from_half_life(100.0, 10.0, 10.0), 50.0);
582 assert_some_approx(
583 remaining_quantity_from_decay_constant(100.0, LN_2 / 10.0, 10.0),
584 50.0,
585 );
586 }
587
588 #[test]
589 fn decayed_fraction_and_elapsed_time_round_trip() {
590 assert_some_approx(decayed_fraction_from_decay_constant(LN_2 / 10.0, 10.0), 0.5);
591 assert_some_approx(
592 decayed_quantity_from_decay_constant(100.0, LN_2 / 10.0, 10.0),
593 50.0,
594 );
595
596 assert_some_approx(elapsed_time_from_remaining_fraction(LN_2 / 10.0, 0.5), 10.0);
597 assert_eq!(elapsed_time_from_remaining_fraction(LN_2 / 10.0, 0.0), None);
598 assert_eq!(elapsed_time_from_remaining_fraction(LN_2 / 10.0, 1.5), None);
599 }
600
601 #[test]
602 fn activity_helpers_validate_inputs() {
603 assert_eq!(activity(2.0, 10.0), Some(20.0));
604 assert_eq!(activity(-1.0, 10.0), None);
605 assert_eq!(activity(1.0, -10.0), None);
606
607 assert_eq!(nuclei_from_activity(20.0, 2.0), Some(10.0));
608 assert_eq!(nuclei_from_activity(20.0, 0.0), None);
609
610 assert_eq!(specific_activity(100.0, 5.0), Some(20.0));
611 assert_eq!(specific_activity(100.0, 0.0), None);
612 }
613
614 #[test]
615 fn mass_energy_helpers_convert_between_common_units() {
616 assert_some_approx(
617 energy_from_mass_defect_kg(1.0),
618 SPEED_OF_LIGHT * SPEED_OF_LIGHT,
619 );
620 assert_eq!(energy_from_mass_defect_kg(-1.0), None);
621
622 assert_some_approx(
623 mass_defect_kg_from_energy(SPEED_OF_LIGHT * SPEED_OF_LIGHT),
624 1.0,
625 );
626
627 assert_some_approx(joules_to_mev(JOULES_PER_MEV), 1.0);
628 assert_some_approx(mev_to_joules(1.0), JOULES_PER_MEV);
629
630 assert_some_approx(
631 binding_energy_mev_from_mass_defect_u(1.0),
632 ATOMIC_MASS_UNIT_MEV_C2,
633 );
634 assert_eq!(binding_energy_per_nucleon(28.0, 4), Some(7.0));
635 assert_eq!(binding_energy_per_nucleon(28.0, 0), None);
636 }
637
638 #[test]
639 fn nuclear_composition_helpers_validate_counts() {
640 assert_eq!(neutron_count(4, 2), Some(2));
641 assert_eq!(neutron_count(2, 4), None);
642
643 assert_eq!(nucleon_count(2, 2), Some(4));
644
645 assert!(is_valid_nuclide_numbers(4, 2));
646 assert!(!is_valid_nuclide_numbers(0, 0));
647 assert!(!is_valid_nuclide_numbers(4, 0));
648 assert!(!is_valid_nuclide_numbers(2, 4));
649 }
650
651 #[test]
652 fn decay_law_methods_delegate_to_public_helpers() {
653 assert_some_approx(
654 DecayLaw::from_half_life(10.0).and_then(|decay_law| decay_law.remaining_fraction(10.0)),
655 0.5,
656 );
657 assert_some_approx(
658 DecayLaw::from_decay_constant(LN_2 / 10.0).and_then(|decay_law| decay_law.half_life()),
659 10.0,
660 );
661 assert_eq!(DecayLaw::from_decay_constant(-1.0), None);
662 }
663
664 #[test]
665 fn nuclide_numbers_methods_expose_component_counts() {
666 let Some(helium) = NuclideNumbers::new(4, 2) else {
667 panic!("expected helium nuclide numbers");
668 };
669
670 assert_eq!(helium.proton_count(), 2);
671 assert_eq!(helium.neutron_count(), 2);
672 assert_eq!(helium.nucleon_count(), 4);
673 assert_eq!(NuclideNumbers::new(2, 4), None);
674 }
675
676 #[test]
677 fn non_finite_inputs_return_none() {
678 assert_eq!(decay_constant_from_half_life(f64::NAN), None);
679 assert_eq!(
680 remaining_fraction_from_decay_constant(1.0, f64::INFINITY),
681 None
682 );
683 assert_eq!(activity(f64::INFINITY, 10.0), None);
684 assert_eq!(energy_from_mass_defect_kg(f64::NAN), None);
685 }
686}