diff --git a/src/dodal/common/general_maths/material_absorption_maths.py b/src/dodal/common/general_maths/material_absorption_maths.py index 518b2aabf5c..4ca6c266527 100644 --- a/src/dodal/common/general_maths/material_absorption_maths.py +++ b/src/dodal/common/general_maths/material_absorption_maths.py @@ -1,22 +1,84 @@ -from pydantic import StrictFloat, validate_call +from typing import Annotated + +from pydantic import ( + Field, + StrictFloat, + validate_call, +) + +from dodal.common.general_maths.transmission_interconversion import ( + attenuation_from_natural_log_of_transmission, + natural_log_of_transmission_from_attenuation, +) @validate_call def photon_mass_attenuation_per_unit_length( energy_kev: StrictFloat, - photon_absorption_factor_per_unit_length: StrictFloat, - energy_dependence_exponent: StrictFloat, + photon_absorption_factor_per_unit_length: Annotated[StrictFloat, Field(ge=0)], + energy_dependence_exponent: Annotated[StrictFloat, Field(le=0)], ) -> float: """Calculates mass attenuation per unit length. Args: energy_kev (StrictFloat): energy - photon_absorption_factor_per_unit_length (StrictFloat): photon absorption factor per - unit length - energy_dependence_exponent (StrictFloat): energy dependence exponent + photon_absorption_factor_per_unit_length(StrictFloat greater than/equal to 0): p + hoton absorption factor per unit length + energy_dependence_exponent (StrictFloat less than/equal to 0): energy + dependence exponent Returns: (float): mass attenuation per unit length. """ - roll_off = energy_kev**energy_dependence_exponent - return photon_absorption_factor_per_unit_length * roll_off + return photon_absorption_factor_per_unit_length * ( + energy_kev**energy_dependence_exponent + ) + + +@validate_call +def attenuation_at_depth_cm( + depth_cm: Annotated[StrictFloat, Field(ge=0)], + absorption_coefficient_per_cm: Annotated[StrictFloat, Field(ge=0)], +) -> float: + """Calculates attenuation in Barnett units, where 1000 Bn equivalent to 1/e, + 0Bn to 1 and 2000 Bn to 1/(e^2). + + Args: + depth_cm (StrictFloat greater than/equal to 0): depth of absorption + absorption_coefficient_per_cm (StrictFloat greater than/equal to 0): absorption + coefficient per cm + + Raises: + ValueError: If either depth_cm or absorption_coefficient are negative, an error + is raised + + Returns: + (float): attenuation in Barnett units + """ + ln_t = -(depth_cm * absorption_coefficient_per_cm) + return attenuation_from_natural_log_of_transmission(ln_t) + + +@validate_call +def thickness_cm_required_to_attenuate( + target_attenuation_bn: Annotated[StrictFloat, Field(ge=0)], + absorption_coefficient_per_cm: Annotated[StrictFloat, Field(ge=1.0e-14)], +) -> float: + """Calculates material depth in cm. + + Args: + target_attenuation_bn (StrictFloat greater than/equal to 0): Target attenuation + to meet in Barnett attenuation units. + absorption_coefficient_per_cm (StrictFloat greater than/equal to 0): absorption + coefficient per cm + + + Raises: + ValueError: if attenuation is below zero, or absorption is below the minimum + meaningful absorption coefficient, a value error is raised + + Returns: + (float): material depth in cm. + """ + ln_target_t = natural_log_of_transmission_from_attenuation(target_attenuation_bn) + return -(ln_target_t / absorption_coefficient_per_cm) diff --git a/tests/common/general_maths/test_material_absorption_maths.py b/tests/common/general_maths/test_material_absorption_maths.py index 94f57347433..2aba7031ab8 100644 --- a/tests/common/general_maths/test_material_absorption_maths.py +++ b/tests/common/general_maths/test_material_absorption_maths.py @@ -4,13 +4,15 @@ from pydantic import ValidationError from dodal.common.general_maths.material_absorption_maths import ( + attenuation_at_depth_cm, photon_mass_attenuation_per_unit_length, + thickness_cm_required_to_attenuate, ) # happy path @pytest.mark.parametrize( - "energy_kev,photon_absorption_factor_per_unit_length,energy_dependence_exponent," + "energy_kev, photon_absorption_factor_per_unit_length, energy_dependence_exponent, " "result", [ (5.042, 1.98e2, -2.717, 2.44170544), # Arbitrary Energy @@ -27,7 +29,48 @@ def test_photon_mass_attenuation_per_unit_length( ): assert photon_mass_attenuation_per_unit_length( energy_kev, photon_absorption_factor_per_unit_length, energy_dependence_exponent - ) == pytest.approx(result, 5.0e-8) + ) == pytest.approx(result) + + +@pytest.mark.parametrize( + "target_attenuation_bn, absorption_coefficient_per_cm, required_cm", + [ + (0, 2.4, 0), # tests attenuator thickness required for transparency is zero + ( + 248.461, + 2.13, + 0.1166483568, + ), # tests attenuator thickness required for arbitrary attenuation + ], +) +def test_thickness_cm_required_to_attenuate( + target_attenuation_bn, absorption_coefficient_per_cm, required_cm +): + assert thickness_cm_required_to_attenuate( + target_attenuation_bn, absorption_coefficient_per_cm + ) == pytest.approx(required_cm, rel=1e-6) + + +@pytest.mark.parametrize( + "depth_cm, absorption_coefficient_per_cm, result", + [ + ( + 0.5, + 2, + 1000, + ), # tests attenuation is 1 kilobarnett at single attenuation length + ( + 1.89, + 0.316, + 597.24, + ), # tests attenuation matches expectations at arbitrary attenuation depth + (0.0, 2.5, 0), # tests attenuation is zero after zero depth + ], +) +def test_attenuation_at_depth_cm(depth_cm, absorption_coefficient_per_cm, result): + assert attenuation_at_depth_cm( + depth_cm, absorption_coefficient_per_cm + ) == pytest.approx(result, rel=1e-6) # inauspicious path @@ -49,3 +92,54 @@ def test_photon_mass_attenuation_per_unit_length_errors_with_invalid_exponent( ): with pytest.raises(ValidationError): photon_mass_attenuation_per_unit_length(3500.0, 1.0, bad_input) + + +def test_thickness_cm_required_to_attenuate_with_transparent_medium(): + with pytest.raises(ValueError): + transparent_medium = 1.0e-15 + thickness_cm_required_to_attenuate(3500.0, transparent_medium) + + +def test_thickness_required_to_attenuate_raises_error_for_gain(): + with pytest.raises(ValidationError): + thickness_cm_required_to_attenuate(-1, 1) + + +@pytest.mark.parametrize("bad_input", ["a", [], None, math.sin, object(), False]) +def test_thickness_required_to_attenuate_raises_error_with_invalid_target_attenuation( + bad_input, +): + with pytest.raises(ValidationError): + thickness_cm_required_to_attenuate(bad_input, 1.0) + + +@pytest.mark.parametrize("bad_input", ["a", [], None, math.sin, object(), False]) +def test_thickness_required_to_attenuate_raises_error_with_invalid_absorption( + bad_input, +): + with pytest.raises(ValidationError): + thickness_cm_required_to_attenuate(1.0, bad_input) + + +@pytest.mark.parametrize("bad_input", [-1, -5, -0.1]) +def test_attenuation_at_depth_raises_error_with_invalid_absorption(bad_input): + with pytest.raises(ValidationError): + attenuation_at_depth_cm(1.0, bad_input) + + +@pytest.mark.parametrize("bad_input", [-1, -5, -0.1]) +def test_attenuation_at_depth_raises_error_for_unphysical_depths(bad_input): + with pytest.raises(ValidationError): + attenuation_at_depth_cm(bad_input, 1.0) + + +@pytest.mark.parametrize("bad_input", ["a", [], None, math.sin, object(), True]) +def test_attenuation_at_depth_raises_error_with_invalid_depth(bad_input): + with pytest.raises(ValidationError): + attenuation_at_depth_cm(bad_input, 1.0) + + +@pytest.mark.parametrize("bad_input", ["a", [], None, math.sin, object(), False]) +def test_attenuation_at_depth_raises_error_with_invalid_attenuation(bad_input): + with pytest.raises(ValidationError): + attenuation_at_depth_cm(1.0, bad_input)